Skip to content

Commit 24acbd4

Browse files
Merge pull request #5090 from linuxfoundation/dev
Add sanction screening service prod
2 parents cfc9131 + 9917f65 commit 24acbd4

38 files changed

Lines changed: 2303 additions & 485 deletions

File tree

.github/workflows/build-pr.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,22 @@ jobs:
111111
- name: Go Lint CLA Legacy Backend
112112
working-directory: cla-backend-legacy
113113
run: make lint
114+
115+
- name: Go Setup CLA SSS Base
116+
working-directory: cla-sss-base
117+
run: |
118+
go mod tidy
119+
120+
- name: Go Build CLA SSS Base
121+
working-directory: cla-sss-base
122+
run: go build ./...
123+
124+
- name: Go Test CLA SSS Base
125+
working-directory: cla-sss-base
126+
run: go test ./...
127+
128+
- name: Go Lint CLA SSS Base
129+
working-directory: cla-sss-base
130+
run: |
131+
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8
132+
golangci-lint run ./...

.github/workflows/deploy-dev.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ env:
2525

2626
concurrency:
2727
group: deploy-dev
28-
cancel-in-progress: true
28+
cancel-in-progress: false
2929

3030
jobs:
3131
build-deploy-dev:

cla-backend-go/cmd/s3_upload/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func init() {
5757
if err != nil {
5858
log.Fatal(err)
5959
}
60-
signService = sign.NewService("", "", companyRepo, nil, nil, nil, nil, configFile.DocuSignPrivateKey, nil, nil, nil, nil, githubOrgService, nil, "", "", nil, nil, nil, nil, nil)
60+
signService = sign.NewService("", "", companyRepo, nil, nil, nil, nil, configFile.DocuSignPrivateKey, nil, nil, nil, nil, githubOrgService, nil, "", "", nil, nil, nil, nil, nil, nil, false)
6161
// projectRepo = repository.NewRepository(awsSession, stage, nil, nil, nil)
6262
utils.SetS3Storage(awsSession, configFile.SignatureFilesBucket)
6363
}

cla-backend-go/cmd/server.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import (
8383

8484
"github.com/linuxfoundation/easycla/cla-backend-go/api_logs"
8585
"github.com/linuxfoundation/easycla/cla-backend-go/signatures"
86+
"github.com/linuxfoundation/easycla/cla-backend-go/sss"
8687
"github.com/linuxfoundation/easycla/cla-backend-go/telemetry"
8788
v2Signatures "github.com/linuxfoundation/easycla/cla-backend-go/v2/signatures"
8889

@@ -448,7 +449,24 @@ func server(localMode bool) http.Handler {
448449
v2GithubActivityService := v2GithubActivity.NewService(gitV1Repository, githubOrganizationsRepo, eventsService, autoEnableService, emailService)
449450

450451
v2ClaGroupService := cla_groups.NewService(v1ProjectService, templateService, v1ProjectClaGroupRepo, v1ClaManagerService, v1SignaturesService, metricsRepo, gerritService, v1RepositoriesService, eventsService)
451-
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)
452+
453+
// Initialize SSS (Sanctions Screening Service) client if configured.
454+
// The sssRequired flag is controlled by the cla-sss-required-{stage} SSM parameter.
455+
sssRequired := configFile.SSS.Required
456+
var sssClient *sss.Client
457+
sssClient, err = sss.NewClientFromPlatformCredentials(configFile.SSS.BaseURL, configFile.SSS.Audience, configFile.Auth0Platform.URL, configFile.Auth0Platform.ClientID, configFile.Auth0Platform.ClientSecret)
458+
if err != nil {
459+
if sssRequired {
460+
log.WithFields(f).WithError(err).Fatal("failed to initialize required SSS client")
461+
}
462+
log.WithFields(f).WithError(err).Warn("failed to initialize optional SSS client, screening will be unavailable")
463+
sssClient = nil
464+
}
465+
if sssRequired && sssClient == nil {
466+
log.WithFields(f).Fatal("SSS is required but not configured")
467+
}
468+
469+
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)
452470

453471
sessionStore, err := dynastore.New(dynastore.Path("/"), dynastore.HTTPOnly(), dynastore.TableName(configFile.SessionStoreTableName), dynastore.DynamoDB(dynamodb.New(awsSession)))
454472
if err != nil {

cla-backend-go/company/mocks/mock_repo.go

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cla-backend-go/company/models.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type DBModel struct {
2525
Updated string `dynamodbav:"date_modified" json:"date_modified"`
2626
Note string `dynamodbav:"note" json:"note"`
2727
IsSanctioned bool `dynamodbav:"is_sanctioned" json:"is_sanctioned"`
28+
SanctionOrigin string `dynamodbav:"sanction_origin" json:"sanction_origin,omitempty"`
2829
Version string `dynamodbav:"version" json:"version"`
2930
}
3031

@@ -87,6 +88,7 @@ func (dbCompanyModel *DBModel) toModel() (*models.Company, error) {
8788
Updated: strfmt.DateTime(updateDateTime),
8889
Note: dbCompanyModel.Note,
8990
IsSanctioned: dbCompanyModel.IsSanctioned,
91+
SanctionOrigin: dbCompanyModel.SanctionOrigin,
9092
Version: dbCompanyModel.Version,
9193
}, nil
9294
}
@@ -148,6 +150,7 @@ func toSwaggerModel(dbCompanyModel *DBModel) (*models.Company, error) {
148150
CompanyName: dbCompanyModel.CompanyName,
149151
SigningEntityName: dbCompanyModel.SigningEntityName,
150152
IsSanctioned: dbCompanyModel.IsSanctioned,
153+
SanctionOrigin: dbCompanyModel.SanctionOrigin,
151154
CompanyExternalID: dbCompanyModel.CompanyExternalID,
152155
CompanyManagerID: dbCompanyModel.CompanyManagerID,
153156
Created: strfmt.DateTime(createdDateTime),

cla-backend-go/company/projections.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func buildCompanyProjection() expression.ProjectionBuilder {
1919
expression.Name("date_modified"),
2020
expression.Name("note"),
2121
expression.Name("is_sanctioned"),
22+
expression.Name("sanction_origin"),
2223
expression.Name("version"),
2324
)
2425
}

cla-backend-go/company/repository.go

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
log "github.com/linuxfoundation/easycla/cla-backend-go/logging"
2222

2323
"github.com/aws/aws-sdk-go/aws"
24+
"github.com/aws/aws-sdk-go/aws/awserr"
2425
"github.com/aws/aws-sdk-go/aws/session"
2526
"github.com/aws/aws-sdk-go/service/dynamodb"
2627
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
@@ -53,6 +54,8 @@ type IRepository interface { //nolint
5354
ApproveCompanyAccessRequest(ctx context.Context, companyInviteID string) error
5455
RejectCompanyAccessRequest(ctx context.Context, companyInviteID string) error
5556
UpdateCompanyAccessList(ctx context.Context, companyID string, companyACL []string) error
57+
UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool, origin string) error
58+
ClearCompanySanctionStatusIfSSS(ctx context.Context, companyID string) (bool, error)
5659
IsCCLAEnabledForCompany(ctx context.Context, companyID string) (bool, error)
5760
}
5861

@@ -1276,7 +1279,119 @@ func (repo repository) UpdateCompanyAccessList(ctx context.Context, companyID st
12761279
return nil
12771280
}
12781281

1279-
// CreateCompany creates a new company record
1282+
// sanctionOriginSSS is the sanction_origin value written by the Sanctions Screening Service.
1283+
const sanctionOriginSSS = "sss"
1284+
1285+
// UpdateCompanySanctionStatus sets is_sanctioned and, when origin is non-empty, sanction_origin.
1286+
// Pass origin="sss" when flagging via SSS; pass origin="" for manual admin updates.
1287+
func (repo repository) UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool, origin string) error {
1288+
f := logrus.Fields{
1289+
"functionName": "company.repository.UpdateCompanySanctionStatus",
1290+
utils.XREQUESTID: ctx.Value(utils.XREQUESTID),
1291+
"companyID": companyID,
1292+
"sanctioned": sanctioned,
1293+
"origin": origin,
1294+
}
1295+
1296+
_, now := utils.CurrentTime()
1297+
1298+
names := map[string]*string{
1299+
"#S": aws.String("is_sanctioned"),
1300+
"#M": aws.String("date_modified"),
1301+
}
1302+
values := map[string]*dynamodb.AttributeValue{
1303+
":s": {BOOL: aws.Bool(sanctioned)},
1304+
":m": {S: aws.String(now)},
1305+
}
1306+
updateExpr := "SET #S = :s, #M = :m"
1307+
1308+
if origin != "" {
1309+
names["#O"] = aws.String("sanction_origin")
1310+
values[":o"] = &dynamodb.AttributeValue{S: aws.String(origin)}
1311+
updateExpr += ", #O = :o"
1312+
} else {
1313+
// Manual/admin update: remove any stale SSS-set origin so the record becomes a
1314+
// sticky admin block (origin absent) that SSS will never auto-clear.
1315+
names["#O"] = aws.String("sanction_origin")
1316+
updateExpr += " REMOVE #O"
1317+
}
1318+
1319+
input := &dynamodb.UpdateItemInput{
1320+
ExpressionAttributeNames: names,
1321+
ExpressionAttributeValues: values,
1322+
TableName: aws.String(repo.companyTableName),
1323+
Key: map[string]*dynamodb.AttributeValue{
1324+
"company_id": {S: aws.String(companyID)},
1325+
},
1326+
UpdateExpression: aws.String(updateExpr),
1327+
}
1328+
1329+
// When SSS sets a block, never overwrite a manual/admin block (is_sanctioned=true
1330+
// with absent or non-"sss" origin). Only set the SSS flag when the company is
1331+
// currently unblocked or already SSS-blocked. A ConditionalCheckFailedException
1332+
// therefore means a manual/admin block is already in place and must be preserved.
1333+
sssSettingBlock := sanctioned && origin == sanctionOriginSSS
1334+
if sssSettingBlock {
1335+
values[":false"] = &dynamodb.AttributeValue{BOOL: aws.Bool(false)}
1336+
input.ConditionExpression = aws.String("attribute_not_exists(#S) OR #S = :false OR #O = :o")
1337+
}
1338+
1339+
if _, err := repo.dynamoDBClient.UpdateItem(input); err != nil {
1340+
if sssSettingBlock {
1341+
if aerr, ok := err.(awserr.Error); ok && aerr.Code() == dynamodb.ErrCodeConditionalCheckFailedException {
1342+
log.WithFields(f).Debugf("company %s already has a manual/admin sanction block; preserving it and not overwriting origin with sss", companyID)
1343+
return nil
1344+
}
1345+
}
1346+
log.WithFields(f).Warnf("error updating company sanction status, error: %v", err)
1347+
return err
1348+
}
1349+
return nil
1350+
}
1351+
1352+
// ClearCompanySanctionStatusIfSSS clears is_sanctioned only when sanction_origin="sss".
1353+
// It returns (cleared, err): cleared is true only when the conditional matched and the
1354+
// record was actually cleared; a ConditionalCheckFailedException (manual/absent origin)
1355+
// returns (false, nil) so callers can leave any manual/admin block in place.
1356+
func (repo repository) ClearCompanySanctionStatusIfSSS(ctx context.Context, companyID string) (bool, error) {
1357+
f := logrus.Fields{
1358+
"functionName": "company.repository.ClearCompanySanctionStatusIfSSS",
1359+
utils.XREQUESTID: ctx.Value(utils.XREQUESTID),
1360+
"companyID": companyID,
1361+
}
1362+
1363+
_, now := utils.CurrentTime()
1364+
1365+
input := &dynamodb.UpdateItemInput{
1366+
TableName: aws.String(repo.companyTableName),
1367+
Key: map[string]*dynamodb.AttributeValue{
1368+
"company_id": {S: aws.String(companyID)},
1369+
},
1370+
UpdateExpression: aws.String("SET #S = :false, #M = :m REMOVE #O"),
1371+
ConditionExpression: aws.String("#O = :sss"),
1372+
ExpressionAttributeNames: map[string]*string{
1373+
"#S": aws.String("is_sanctioned"),
1374+
"#M": aws.String("date_modified"),
1375+
"#O": aws.String("sanction_origin"),
1376+
},
1377+
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
1378+
":false": {BOOL: aws.Bool(false)},
1379+
":m": {S: aws.String(now)},
1380+
":sss": {S: aws.String(sanctionOriginSSS)},
1381+
},
1382+
}
1383+
1384+
if _, err := repo.dynamoDBClient.UpdateItem(input); err != nil {
1385+
if aerr, ok := err.(awserr.Error); ok && aerr.Code() == dynamodb.ErrCodeConditionalCheckFailedException {
1386+
log.WithFields(f).Debugf("sanction_origin != sss for company %s; not auto-clearing (manual/admin block)", companyID)
1387+
return false, nil
1388+
}
1389+
log.WithFields(f).Warnf("error clearing company sanction status: %v", err)
1390+
return false, err
1391+
}
1392+
return true, nil
1393+
}
1394+
12801395
func (repo repository) CreateCompany(ctx context.Context, in *models.Company) (*models.Company, error) {
12811396
f := logrus.Fields{
12821397
"functionName": "company.repository.CreateCompany",

cla-backend-go/config/config.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ type Config struct {
9898

9999
// DocuSignPrivateKey is the private key for the DocuSign API
100100
DocuSignPrivateKey string `json:"docuSignPrivateKey"`
101+
102+
// SSS holds the Sanctions Screening Service client configuration
103+
SSS SSS `json:"sss"`
101104
}
102105

103106
// Auth0 model
@@ -116,6 +119,32 @@ type Auth0Platform struct {
116119
URL string `json:"url"`
117120
}
118121

122+
// SSS holds the Sanctions Screening Service client configuration. The SSS
123+
// integration reuses the shared LFX platform M2M (Auth0Platform) credentials,
124+
// so only the SSS-specific base URL and audience are configured here.
125+
//
126+
// Sanctions screening is required by policy in deployed environments - these
127+
// values are only loaded leniently (see config/ssm.go) so the SSM parameters
128+
// can be provisioned before the feature is switched on. Whether a missing
129+
// configuration is tolerated (no-op) or fatal must be enforced by the caller
130+
// per stage; it must NOT be silently skipped in dev/staging/prod.
131+
type SSS struct {
132+
// BaseURL is the SSS host root WITHOUT the /api/v1 suffix - the client
133+
// appends /api/v1/organizations/status itself (e.g.
134+
// https://sanctions-screening.dev.v2.cluster.linuxfound.info)
135+
BaseURL string `json:"base_url"`
136+
// Audience is the Auth0 resource-server identifier for SSS, including any
137+
// trailing slash - it must match the auth0-terraform resource server
138+
// identifier exactly (e.g.
139+
// https://sanctions-screening.dev.v2.cluster.linuxfound.info/)
140+
Audience string `json:"audience"`
141+
// Required is a flag controlling whether SSS screening is required or optional.
142+
// When true, any SSS errors (unavailable, timeout, config errors, or missing domain)
143+
// will block the operation. When false, SSS errors are logged but do not block.
144+
// This flag is loaded from the SSM parameter cla-sss-required-{stage}.
145+
Required bool `json:"required"`
146+
}
147+
119148
// Docraptor model
120149
type Docraptor struct {
121150
APIKey string `json:"apiKey"`

0 commit comments

Comments
 (0)