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
7 changes: 4 additions & 3 deletions cla-backend-go/company/mocks/mock_repo.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cla-backend-go/company/projections.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func buildCompanyProjection() expression.ProjectionBuilder {
expression.Name("date_modified"),
expression.Name("note"),
expression.Name("is_sanctioned"),
expression.Name("sanction_origin"),
expression.Name("version"),
)
}
Expand Down
40 changes: 33 additions & 7 deletions cla-backend-go/company/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ type IRepository interface { //nolint
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
ClearCompanySanctionStatusIfSSS(ctx context.Context, companyID string) (bool, error)
IsCCLAEnabledForCompany(ctx context.Context, companyID string) (bool, error)
}

Expand Down Expand Up @@ -1279,6 +1279,9 @@ func (repo repository) UpdateCompanyAccessList(ctx context.Context, companyID st
return nil
}

// sanctionOriginSSS is the sanction_origin value written by the Sanctions Screening Service.
const sanctionOriginSSS = "sss"

// 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 {
Expand Down Expand Up @@ -1306,6 +1309,11 @@ func (repo repository) UpdateCompanySanctionStatus(ctx context.Context, companyI
names["#O"] = aws.String("sanction_origin")
values[":o"] = &dynamodb.AttributeValue{S: aws.String(origin)}
updateExpr += ", #O = :o"
} else {
// Manual/admin update: remove any stale SSS-set origin so the record becomes a
// sticky admin block (origin absent) that SSS will never auto-clear.
names["#O"] = aws.String("sanction_origin")
updateExpr += " REMOVE #O"
}

input := &dynamodb.UpdateItemInput{
Expand All @@ -1318,16 +1326,34 @@ func (repo repository) UpdateCompanySanctionStatus(ctx context.Context, companyI
UpdateExpression: aws.String(updateExpr),
}

// When SSS sets a block, never overwrite a manual/admin block (is_sanctioned=true
// with absent or non-"sss" origin). Only set the SSS flag when the company is
// currently unblocked or already SSS-blocked. A ConditionalCheckFailedException
// therefore means a manual/admin block is already in place and must be preserved.
sssSettingBlock := sanctioned && origin == sanctionOriginSSS
if sssSettingBlock {
values[":false"] = &dynamodb.AttributeValue{BOOL: aws.Bool(false)}
input.ConditionExpression = aws.String("attribute_not_exists(#S) OR #S = :false OR #O = :o")
}

if _, err := repo.dynamoDBClient.UpdateItem(input); err != nil {
if sssSettingBlock {
if aerr, ok := err.(awserr.Error); ok && aerr.Code() == dynamodb.ErrCodeConditionalCheckFailedException {
log.WithFields(f).Debugf("company %s already has a manual/admin sanction block; preserving it and not overwriting origin with sss", companyID)
return 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 {
// It returns (cleared, err): cleared is true only when the conditional matched and the
// record was actually cleared; a ConditionalCheckFailedException (manual/absent origin)
// returns (false, nil) so callers can leave any manual/admin block in place.
func (repo repository) ClearCompanySanctionStatusIfSSS(ctx context.Context, companyID string) (bool, error) {
f := logrus.Fields{
"functionName": "company.repository.ClearCompanySanctionStatusIfSSS",
utils.XREQUESTID: ctx.Value(utils.XREQUESTID),
Expand All @@ -1351,19 +1377,19 @@ func (repo repository) ClearCompanySanctionStatusIfSSS(ctx context.Context, comp
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":false": {BOOL: aws.Bool(false)},
":m": {S: aws.String(now)},
":sss": {S: aws.String("sss")},
":sss": {S: aws.String(sanctionOriginSSS)},
},
}

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
return false, nil
}
log.WithFields(f).Warnf("error clearing company sanction status: %v", err)
return err
return false, err
}
return nil
return true, nil
}

func (repo repository) CreateCompany(ctx context.Context, in *models.Company) (*models.Company, error) {
Expand Down
24 changes: 24 additions & 0 deletions cla-backend-go/signatures/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,17 @@ func (s service) CreateOrUpdateEmployeeSignature(ctx context.Context, claGroupMo
"companyID": companyModel.CompanyID,
}

// Sanctions gate: never auto-create employee (ECLA) signatures for a sanctioned
// company. is_sanctioned is the persisted gate (SSS origin="sss" or a manual/admin
// block); both must block ECLA creation (auto-create toggle and approval-list edits).
// By design this enforces the persisted flag, not a live SSS call: the live screen runs
// at the sign/request entry points (CCLA request + legacy ECLA precheck) and keeps this
// flag fresh; secondary paths intentionally honor the persisted state.
if companyModel.IsSanctioned {
log.WithFields(f).Warnf("company %s is sanctioned (origin=%q); refusing to auto-create employee (ECLA) signatures", companyModel.CompanyID, companyModel.SanctionOrigin)
return nil, fmt.Errorf("company %s is sanctioned; employee (ECLA) signatures cannot be created", companyModel.CompanyID)
}

// Most of the following business logic is all the same - however, we need to handle the different types of approval lists entries and process them in the same way
// We build a list of users to process - this is a list of simple user models that contain the email, GitHub username, and GitLab username - typically only one of the values in the model will be set
userList, userErr := s.createOrGetEmployeeModels(ctx, claGroupModel, companyModel, corporateSignatureModel)
Expand Down Expand Up @@ -1517,6 +1528,19 @@ func (s service) ProcessEmployeeSignature(ctx context.Context, companyModel *mod
"projectID": claGroupModel.ProjectID,
"userID": user.UserID,
}

// Sanctions gate: a sanctioned company's employees are not authorized. is_sanctioned
// is the persisted gate (SSS origin="sss" or a manual/admin block); honor it here so
// employee-acknowledgement (ECLA) authorization fails for sanctioned companies on
// GitHub PR checks and authorization queries. By design this enforces the persisted
// flag, not a live SSS call (the live screen at the sign/request entry points keeps it
// fresh).
if companyModel.IsSanctioned {
log.WithFields(f).Warnf("company %s is sanctioned (origin=%q); employee acknowledgement not authorized", companyModel.CompanyID, companyModel.SanctionOrigin)
notSigned := false
return &notSigned, nil
}

var wg sync.WaitGroup
resultChannel := make(chan *EmployeeModel)
errorChannel := make(chan error)
Expand Down
15 changes: 12 additions & 3 deletions cla-backend-go/v2/gitlab-activity/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ func (s *service) ProcessMergeActivity(ctx context.Context, secretToken string,
log.WithFields(f).WithError(signedCheckErr).Warnf("problem checking if user : %s (%d) has signed - assuming not signed", gitlabUser.Username, gitlabUser.ID)
missingUsers = append(missingUsers, &gatedGitlabUser{
User: gitlabUser,
err: err,
err: signedCheckErr,
})
continue
}
Expand All @@ -273,7 +273,7 @@ func (s *service) ProcessMergeActivity(ctx context.Context, secretToken string,
log.WithFields(f).Infof("gitlabUser: %s (%d) has NOT signed", gitlabUser.Username, gitlabUser.ID)
missingUsers = append(missingUsers, &gatedGitlabUser{
User: gitlabUser,
err: err,
err: nil,
})
}
}
Expand Down Expand Up @@ -516,13 +516,22 @@ func (s *service) isSigned(ctx context.Context, userModel *models.User, claGroup
}

companyID := userModel.CompanyID
_, err = s.companyRepository.GetCompany(ctx, companyID)
companyModel, err := s.companyRepository.GetCompany(ctx, companyID)
if err != nil {
msg := fmt.Sprintf("can't load company record: %s for user: %s (%s), error: %v", companyID, userModel.Username, userModel.UserID, err)
log.WithFields(f).Errorf("%s", msg)
return false, fmt.Errorf("%s", msg)
}

// Sanctions gate: a sanctioned company's employees are not authorized. Honor the
// persisted is_sanctioned gate (SSS origin="sss" or a manual/admin block) so GitLab
// MR checks fail for sanctioned companies. By design this enforces the persisted flag,
// not a live SSS call (the live screen at the sign/request entry points keeps it fresh).
if companyModel != nil && companyModel.IsSanctioned {
log.WithFields(f).Warnf("company %s is sanctioned (origin=%q); GitLab contributor not authorized", companyID, companyModel.SanctionOrigin)
return false, fmt.Errorf("company %s is sanctioned", companyID)
}

corporateSignature, err := s.signatureRepository.GetCorporateSignature(ctx, claGroupID, companyID, aws.Bool(true), aws.Bool(true))
if err != nil {
msg := fmt.Sprintf("can't load company signature record for company: %s for user : %s (%s), error : %v", companyID, userModel.Username, userModel.UserID, err)
Expand Down
12 changes: 9 additions & 3 deletions cla-backend-go/v2/gitlab_organizations/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -858,13 +858,19 @@ func (s *Service) InitiateSignRequest(ctx context.Context, req *http.Request, gi
}

companyID := claUser.CompanyID
_, err = s.companyRepository.GetCompany(ctx, companyID)
if err != nil {
msg := fmt.Sprintf("can't load company record: %s for user: %s (%s), error: %v", companyID, claUser.Username, claUser.UserID, err)
companyModel, companyErr := s.companyRepository.GetCompany(ctx, companyID)
if companyErr != nil {
msg := fmt.Sprintf("can't load company record: %s for user: %s (%s), error: %v", companyID, claUser.Username, claUser.UserID, companyErr)
log.WithFields(f).Errorf("%s", msg)
return &consoleURL, nil
}

// Sanctions gate: do not activate a corporate signature for a sanctioned company.
if companyModel != nil && companyModel.IsSanctioned {
log.WithFields(f).Warnf("company %s is sanctioned (origin=%q); not activating signature for GitLab user %s", companyID, companyModel.SanctionOrigin, claUser.UserID)
return &consoleURL, nil
}

corporateSignature, err := s.signatureRepo.GetCorporateSignature(ctx, gitlabRepo.RepositoryClaGroupID, companyID, aws.Bool(false), aws.Bool(true))
if err != nil {
msg := fmt.Sprintf("can't load company signature record for company: %s for user : %s (%s), error : %v", companyID, claUser.Username, claUser.UserID, err)
Expand Down
Loading
Loading