Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cla-backend-go/cmd/s3_upload/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
7 changes: 4 additions & 3 deletions cla-backend-go/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions cla-backend-go/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 15 additions & 9 deletions cla-backend-go/config/ssm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion cla-backend-go/v2/sign/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ type service struct {
gerritService gerrits.Service
sssClient *sss.Client
sssRequired bool
sssEnabled bool
complianceCache map[string]complianceCacheEntry
complianceCacheMu *sync.Mutex
}
Expand All @@ -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,
Expand All @@ -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{},
}
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 27 additions & 7 deletions cla-backend-go/v2/sign/service_sss_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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.
Expand Down Expand Up @@ -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)},
},
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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")
}
}
33 changes: 27 additions & 6 deletions cla-backend-legacy/internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ type Handlers struct {
userService *userservicelegacy.Client
sssClient *sss.Client
sssRequired bool
sssEnabled bool
}

func NewHandlers() *Handlers {
Expand Down Expand Up @@ -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)
Expand All @@ -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)")
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading