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
3 changes: 3 additions & 0 deletions cla-backend-go/company/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type DBModel struct {
Note string `dynamodbav:"note" json:"note"`
IsSanctioned bool `dynamodbav:"is_sanctioned" json:"is_sanctioned"`
SanctionOrigin string `dynamodbav:"sanction_origin" json:"sanction_origin,omitempty"`
SanctionedDate string `dynamodbav:"sanctioned_date" json:"sanctioned_date,omitempty"`
Version string `dynamodbav:"version" json:"version"`
}

Expand Down Expand Up @@ -89,6 +90,7 @@ func (dbCompanyModel *DBModel) toModel() (*models.Company, error) {
Note: dbCompanyModel.Note,
IsSanctioned: dbCompanyModel.IsSanctioned,
SanctionOrigin: dbCompanyModel.SanctionOrigin,
SanctionedDate: dbCompanyModel.SanctionedDate,
Version: dbCompanyModel.Version,
}, nil
}
Expand Down Expand Up @@ -151,6 +153,7 @@ func toSwaggerModel(dbCompanyModel *DBModel) (*models.Company, error) {
SigningEntityName: dbCompanyModel.SigningEntityName,
IsSanctioned: dbCompanyModel.IsSanctioned,
SanctionOrigin: dbCompanyModel.SanctionOrigin,
SanctionedDate: dbCompanyModel.SanctionedDate,
CompanyExternalID: dbCompanyModel.CompanyExternalID,
CompanyManagerID: dbCompanyModel.CompanyManagerID,
Created: strfmt.DateTime(createdDateTime),
Expand Down
1 change: 1 addition & 0 deletions cla-backend-go/company/projections.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func buildCompanyProjection() expression.ProjectionBuilder {
expression.Name("note"),
expression.Name("is_sanctioned"),
expression.Name("sanction_origin"),
expression.Name("sanctioned_date"),
expression.Name("version"),
)
}
Expand Down
95 changes: 60 additions & 35 deletions cla-backend-go/company/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,7 @@ func buildCompanyModels(ctx context.Context, results *dynamodb.ScanOutput) ([]mo
Created string `json:"date_created"`
Note string `json:"note"`
IsSanctioned bool `json:"is_sanctioned"`
SanctionedDate string `json:"sanctioned_date"`
Modified string `json:"date_modified"`
}

Expand Down Expand Up @@ -829,6 +830,7 @@ func buildCompanyModels(ctx context.Context, results *dynamodb.ScanOutput) ([]mo
Created: strfmt.DateTime(createdDateTime),
Note: dbCompany.Note,
IsSanctioned: dbCompany.IsSanctioned,
SanctionedDate: dbCompany.SanctionedDate,
Updated: strfmt.DateTime(modifiedDateTime),
})
}
Expand Down Expand Up @@ -1282,6 +1284,58 @@ func (repo repository) UpdateCompanyAccessList(ctx context.Context, companyID st
// sanctionOriginSSS is the sanction_origin value written by the Sanctions Screening Service.
const sanctionOriginSSS = "sss"

// sanctionUpdate is the DynamoDB update for one sanction status change.
type sanctionUpdate struct {
expression string
condition *string
names map[string]*string
values map[string]*dynamodb.AttributeValue
}

// buildSanctionUpdate assembles the update for UpdateCompanySanctionStatus. All SET assignments
// stay contiguous ahead of any REMOVE, as DynamoDB requires.
func buildSanctionUpdate(sanctioned bool, origin, now string) sanctionUpdate {
update := sanctionUpdate{
expression: "SET #S = :s, #M = :m",
names: map[string]*string{
"#S": aws.String("is_sanctioned"),
"#M": aws.String("date_modified"),
"#O": aws.String("sanction_origin"),
},
values: map[string]*dynamodb.AttributeValue{
":s": {BOOL: aws.Bool(sanctioned)},
":m": {S: aws.String(now)},
},
}

// Setting the flag stamps sanctioned_date; clearing it leaves the stored date alone.
if sanctioned {
update.names["#D"] = aws.String("sanctioned_date")
update.values[":d"] = &dynamodb.AttributeValue{S: aws.String(now)}
update.expression += ", #D = :d"
Comment thread
lukaszgryglicki marked this conversation as resolved.
}

if origin != "" {
update.values[":o"] = &dynamodb.AttributeValue{S: aws.String(origin)}
update.expression += ", #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.
update.expression += " REMOVE #O"
}

// 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.
if sanctioned && origin == sanctionOriginSSS {
update.values[":false"] = &dynamodb.AttributeValue{BOOL: aws.Bool(false)}
update.condition = aws.String("attribute_not_exists(#S) OR #S = :false OR #O = :o")
}

return update
}

// 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 All @@ -1294,50 +1348,21 @@ func (repo repository) UpdateCompanySanctionStatus(ctx context.Context, companyI
}

_, 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"
} 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"
}
update := buildSanctionUpdate(sanctioned, origin, now)

input := &dynamodb.UpdateItemInput{
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
ExpressionAttributeNames: update.names,
ExpressionAttributeValues: update.values,
TableName: aws.String(repo.companyTableName),
Key: map[string]*dynamodb.AttributeValue{
"company_id": {S: aws.String(companyID)},
},
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")
UpdateExpression: aws.String(update.expression),
ConditionExpression: update.condition,
}

if _, err := repo.dynamoDBClient.UpdateItem(input); err != nil {
if sssSettingBlock {
if update.condition != nil {
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
Expand Down
94 changes: 94 additions & 0 deletions cla-backend-go/company/repository_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright The Linux Foundation and each contributor to CommunityBridge.
// SPDX-License-Identifier: MIT

package company

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBuildSanctionUpdate(t *testing.T) {
const now = "2026-08-20T10:11:12Z"

tests := []struct {
name string
sanctioned bool
origin string
expression string
condition string
stampedDate bool
}{
{
name: "sss flags the company",
sanctioned: true,
origin: sanctionOriginSSS,
expression: "SET #S = :s, #M = :m, #D = :d, #O = :o",
condition: "attribute_not_exists(#S) OR #S = :false OR #O = :o",
stampedDate: true,
},
{
name: "sss clears the company",
sanctioned: false,
origin: sanctionOriginSSS,
expression: "SET #S = :s, #M = :m, #O = :o",
},
{
name: "admin flags the company",
sanctioned: true,
origin: "",
expression: "SET #S = :s, #M = :m, #D = :d REMOVE #O",
stampedDate: true,
},
{
name: "admin clears the company",
sanctioned: false,
origin: "",
expression: "SET #S = :s, #M = :m REMOVE #O",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
update := buildSanctionUpdate(tc.sanctioned, tc.origin, now)

assert.Equal(t, tc.expression, update.expression)
if tc.condition == "" {
assert.Nil(t, update.condition, "only an SSS-set flag is conditional")
} else {
require.NotNil(t, update.condition)
assert.Equal(t, tc.condition, *update.condition, "the manual/admin block must stay protected")
}

if tc.stampedDate {
require.Contains(t, update.names, "#D")
assert.Equal(t, "sanctioned_date", *update.names["#D"])
require.Contains(t, update.values, ":d")
assert.Equal(t, now, *update.values[":d"].S, "the flag and the date are stamped with the same time")
} else {
assert.NotContains(t, update.names, "#D", "clearing the flag leaves the stored date alone")
assert.NotContains(t, update.values, ":d")
}

assert.Equal(t, tc.sanctioned, *update.values[":s"].BOOL)
assert.Equal(t, now, *update.values[":m"].S)

// Every declared name and value has to be referenced, or DynamoDB rejects the update.
for name := range update.names {
assert.Contains(t, update.expression+condition(update), name)
}
for value := range update.values {
assert.Contains(t, update.expression+condition(update), value)
}
})
}
}

func condition(update sanctionUpdate) string {
if update.condition == nil {
return ""
}
return " " + *update.condition
}
4 changes: 4 additions & 0 deletions cla-backend-go/swagger/common/company.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ properties:
type: string
description: "Source of the sanction flag (e.g. sss)"
example: "sss"
sanctionedDate:
type: string
description: "When the sanction flag was last set; kept after it is cleared"
example: "2026-08-20T10:11:12Z"

version:
type: string
Expand Down
2 changes: 1 addition & 1 deletion cla-backend-go/swagger/common/my-cla.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ properties:
description: True when the employer is currently flagged by sanctions screening - always present, always false on ICLA rows
flaggedAt:
type: string
description: When the flag was observed - currently the response time, as no sanction timestamp is stored yet; present only when flagged is true
description: The employer's stored sanctioned_date, stamped at the first live detection - the response time only when no date is stored and stamping it failed; present only when flagged is true
flaggedCheck:
type: string
enum: [live, stored, unavailable]
Expand Down
19 changes: 17 additions & 2 deletions cla-backend-go/v2/my_clas/cla_managers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func TestGetMyClasFlaggedAndClaManager(t *testing.T) {
userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"}
companies := &fakeCompanies{byID: map[string]*v1Models.Company{
"company-1": {CompanyID: "company-1", CompanyName: "Good Corp"},
"company-2": {CompanyID: "company-2", CompanyName: "Sanctioned Corp", IsSanctioned: true},
"company-2": {CompanyID: "company-2", CompanyName: "Sanctioned Corp", IsSanctioned: true, SanctionedDate: "2024-01-15T10:11:12.000000+0000"},
}}
signaturesService := &fakeSignatures{
cclas: map[string]*v1Models.Signature{
Expand Down Expand Up @@ -145,7 +145,7 @@ func TestGetMyClasFlaggedAndClaManager(t *testing.T) {

sanctioned := byID["sig-sanctioned"]
assert.True(t, sanctioned.Flagged, "a sanctioned employer flags the ECLA")
assert.NotEmpty(t, sanctioned.FlaggedAt)
assert.Equal(t, "2024-01-15T10:11:12Z", sanctioned.FlaggedAt, "the stored sanctioned_date is returned in RFC3339, not the response time")
assert.Equal(t, models.MyClaStatusRevoked, sanctioned.Status, "the Revoked state is system-set from sanctions")
assert.False(t, sanctioned.Valid)
assert.False(t, sanctioned.ClaManager, "a sanctioned employer carries no CLA manager action")
Expand Down Expand Up @@ -332,6 +332,21 @@ func TestCreateMyClaManagerRequestRecipientDedupe(t *testing.T) {
assert.Equal(t, []string{"manager-one@corp.example.org"}, (*sent)[0].recipients)
}

func TestCreateMyClaManagerRequestSharedEmailDedupe(t *testing.T) {
repo, signaturesService, companies := managersFixture()
signaturesService.cclas["cla-group-1|company-1"].SignatureACL[1].Emails = []string{"Manager-One@Corp.Example.org"}
svc, _, sent := newRequestTestService(repo, signaturesService, companies)

result, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla",
requestInput("removal", []string{"manager-one", "manager-two"}, ""))
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, []string{"manager-one", "manager-two"}, result.Recipients, "both selected managers are still reported")

require.Len(t, *sent, 1)
assert.Equal(t, []string{"manager-one@corp.example.org"}, (*sent)[0].recipients, "two managers sharing an address are mailed once")
}

func TestCreateMyClaManagerRequestZeroManagers(t *testing.T) {
repo, _, companies := managersFixture()
svc, eventsService, sent := newRequestTestService(repo, &fakeSignatures{}, companies)
Expand Down
4 changes: 2 additions & 2 deletions cla-backend-go/v2/my_clas/sanctions.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ type sssScreener struct {
getOrganization func(ctx context.Context, orgID string) (*orgModels.Organization, error)
}

// NewSanctionsScreener returns a read-only screener: unlike the signing flow it never persists what
// it observes, so the listing stays a pure read
// NewSanctionsScreener returns a read-only screener: it answers the question and never writes.
// Persisting a first live detection is the caller's job (see service.persistLiveSanction)
func NewSanctionsScreener(client *sss.Client, enabled, required bool) SanctionsScreener {
screener := &sssScreener{
enabled: enabled,
Expand Down
Loading
Loading