Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add endpoint to manually enrich match with full details. #872

Merged
merged 2 commits into from
Feb 26, 2025
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
16 changes: 16 additions & 0 deletions api/handle_sanction_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,19 @@ func handleSearchSanctionCheck(uc usecases.Usecases) func(c *gin.Context) {
}))
}
}

func handleEnrichSanctionCheckMatch(uc usecases.Usecases) func(c *gin.Context) {
return func(c *gin.Context) {
ctx := c.Request.Context()
matchId := c.Param("id")

uc := usecasesWithCreds(ctx, uc).NewSanctionCheckUsecase()
newMatch, err := uc.EnrichMatch(ctx, matchId)

if presentError(ctx, c, err) {
return
}

c.JSON(http.StatusOK, dto.AdaptSanctionCheckMatchDto(newMatch))
}
}
1 change: 1 addition & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ func addRoutes(r *gin.Engine, conf Configuration, uc usecases.Usecases, auth Aut
router.GET("/sanction-checks/:sanctionCheckId/files/:fileId", tom,
handleDownloadSanctionCheckMatchFile(uc))
router.PATCH("/sanction-checks/matches/:id", tom, handleUpdateSanctionCheckMatchStatus(uc))
router.POST("/sanction-checks/matches/:id/enrich", tom, handleEnrichSanctionCheckMatch(uc))

router.GET("/scenario-publications", tom, handleListScenarioPublications(uc))
router.POST("/scenario-publications", tom, handleCreateScenarioPublication(uc))
Expand Down
2 changes: 2 additions & 0 deletions dto/sanction_check_dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type SanctionCheckMatchDto struct {
Datasets []string `json:"datasets"`
UniqueCounterpartyIdentifier *string `json:"unique_counterparty_identifier"`
Payload json.RawMessage `json:"payload"`
Enriched bool `json:"enriched"`
Comments []SanctionCheckMatchCommentDto `json:"comments"`
}

Expand All @@ -99,6 +100,7 @@ func AdaptSanctionCheckMatchDto(m models.SanctionCheckMatch) SanctionCheckMatchD
QueryIds: m.QueryIds,
Datasets: make([]string, 0),
Payload: m.Payload,
Enriched: m.Enriched,
UniqueCounterpartyIdentifier: m.UniqueCounterpartyIdentifier,
Comments: pure_utils.Map(m.Comments, AdaptSanctionCheckMatchCommentDto),
}
Expand Down
1 change: 1 addition & 0 deletions models/sanction_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ type SanctionCheckMatch struct {
QueryIds []string
UniqueCounterpartyIdentifier *string
Payload []byte
Enriched bool
ReviewedBy *string
Comments []SanctionCheckMatchComment
}
Expand Down
2 changes: 2 additions & 0 deletions repositories/dbmodels/db_sanction_check_match.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type DBSanctionCheckMatch struct {
QueryIds []string `db:"query_ids"`
CounterpartyId *string `db:"counterparty_id"`
Payload json.RawMessage `db:"payload"`
Enriched bool `db:"enriched"`
ReviewedBy *string `db:"reviewed_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Expand All @@ -37,6 +38,7 @@ func AdaptSanctionCheckMatch(dto DBSanctionCheckMatch) (models.SanctionCheckMatc
QueryIds: dto.QueryIds,
UniqueCounterpartyIdentifier: dto.CounterpartyId,
Payload: dto.Payload,
Enriched: dto.Enriched,
}

return match, nil
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- +goose Up

alter table sanction_check_matches
add column enriched bool default false;

-- +goose Down

alter table sanction_check_matches
drop column enriched;
79 changes: 61 additions & 18 deletions repositories/opensanctions_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,56 @@ func (repo OpenSanctionsRepository) Search(ctx context.Context, query models.Ope
return sanctionCheck, err
}

func (repo OpenSanctionsRepository) EnrichMatch(ctx context.Context, match models.SanctionCheckMatch) ([]byte, error) {
requestUrl := fmt.Sprintf("%s/entities/%s", repo.opensanctions.Host(), match.EntityId)

if qs := repo.buildQueryString(nil, nil); len(qs) > 0 {
requestUrl = fmt.Sprintf("%s?%s", requestUrl, qs.Encode())
}

req, err := http.NewRequest(http.MethodGet, requestUrl, nil)
if err != nil {
return nil, err
}

repo.authenticateRequest(req)

resp, err := repo.opensanctions.Client().Do(req)
if err != nil {
return nil,
errors.Wrap(err, "could not enrich sanction check match")
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf(
"sanction check API returned status %d on enrichment", resp.StatusCode)
}

defer resp.Body.Close()

var newMatch json.RawMessage

if err := json.NewDecoder(resp.Body).Decode(&newMatch); err != nil {
return nil, errors.Wrap(err,
"could not parse sanction check response")
}

return newMatch, nil
}

func (repo OpenSanctionsRepository) authenticateRequest(req *http.Request) {
if repo.opensanctions.IsSelfHosted() {
switch repo.opensanctions.AuthMethod() {
case infra.OPEN_SANCTIONS_AUTH_BEARER:
req.Header.Set("authorization", "Bearer "+repo.opensanctions.Credentials())
case infra.OPEN_SANCTIONS_AUTH_BASIC:
u, p, _ := strings.Cut(repo.opensanctions.Credentials(), ":")

req.SetBasicAuth(u, p)
}
}
}

func (repo OpenSanctionsRepository) searchRequest(ctx context.Context,
query models.OpenSanctionsQuery,
) (*http.Request, []byte, error) {
Expand All @@ -242,44 +292,37 @@ func (repo OpenSanctionsRepository) searchRequest(ctx context.Context,

requestUrl := fmt.Sprintf("%s/match/sanctions", repo.opensanctions.Host())

if qs := repo.buildQueryString(query.Config, query); len(qs) > 0 {
if qs := repo.buildQueryString(&query.Config, &query); len(qs) > 0 {
requestUrl = fmt.Sprintf("%s?%s", requestUrl, qs.Encode())
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestUrl, &body)

if repo.opensanctions.IsSelfHosted() {
switch repo.opensanctions.AuthMethod() {
case infra.OPEN_SANCTIONS_AUTH_BEARER:
req.Header.Set("authorization", "Bearer "+repo.opensanctions.Credentials())
case infra.OPEN_SANCTIONS_AUTH_BASIC:
u, p, _ := strings.Cut(repo.opensanctions.Credentials(), ":")

req.SetBasicAuth(u, p)
}
}
repo.authenticateRequest(req)

return req, rawQuery.Bytes(), err
}

func (repo OpenSanctionsRepository) buildQueryString(cfg models.SanctionCheckConfig, query models.OpenSanctionsQuery) url.Values {
func (repo OpenSanctionsRepository) buildQueryString(cfg *models.SanctionCheckConfig, query *models.OpenSanctionsQuery) url.Values {
qs := url.Values{}

if repo.opensanctions.AuthMethod() == infra.OPEN_SANCTIONS_AUTH_SAAS &&
len(repo.opensanctions.Credentials()) > 0 {
qs.Set("api_key", repo.opensanctions.Credentials())
}

if len(cfg.Datasets) > 0 {
if cfg != nil && len(cfg.Datasets) > 0 {
qs["include_dataset"] = cfg.Datasets
}

// Unless determined otherwise, we do not need those results that are *not*
// matches. They could still be filtered further down the chain, but we do not need them returned.
qs.Set("threshold", fmt.Sprintf("%.1f", float64(query.OrgConfig.MatchThreshold)/100))
qs.Set("cutoff", fmt.Sprintf("%.1f", float64(query.OrgConfig.MatchThreshold)/100))
if query != nil {
// Unless determined otherwise, we do not need those results that are *not*
// matches. They could still be filtered further down the chain, but we do not need them returned.
qs.Set("threshold", fmt.Sprintf("%.1f", float64(query.OrgConfig.MatchThreshold)/100))
qs.Set("cutoff", fmt.Sprintf("%.1f", float64(query.OrgConfig.MatchThreshold)/100))

qs.Set("limit", fmt.Sprintf("%d", query.OrgConfig.MatchLimit+query.LimitIncrease))
qs.Set("limit", fmt.Sprintf("%d", query.OrgConfig.MatchLimit+query.LimitIncrease))
}

return qs
}
18 changes: 18 additions & 0 deletions repositories/sanction_check_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,24 @@ func (*MarbleDbRepository) UpdateSanctionCheckStatus(ctx context.Context, exec E
)
}

func (*MarbleDbRepository) UpdateSanctionCheckMatchPayload(ctx context.Context, exec Executor,
match models.SanctionCheckMatch, newPayload []byte,
) (models.SanctionCheckMatch, error) {
if err := validateMarbleDbExecutor(exec); err != nil {
return models.SanctionCheckMatch{}, err
}

sql := NewQueryBuilder().
Update(dbmodels.TABLE_SANCTION_CHECK_MATCHES).
Set("payload", newPayload).
Set("enriched", true).
Set("updated_at", "NOW()").
Where(squirrel.Eq{"id": match.Id}).Suffix(fmt.Sprintf("RETURNING %s",
strings.Join(dbmodels.SelectSanctionCheckMatchesColumn, ",")))

return SqlToModel(ctx, exec, sql, dbmodels.AdaptSanctionCheckMatch)
}

func (*MarbleDbRepository) ListSanctionCheckMatches(
ctx context.Context,
exec Executor,
Expand Down
32 changes: 32 additions & 0 deletions usecases/sanction_check_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type SanctionCheckProvider interface {
GetCatalog(ctx context.Context) (models.OpenSanctionsCatalog, error)
GetLatestLocalDataset(context.Context) (models.OpenSanctionsDatasetFreshness, error)
Search(context.Context, models.OpenSanctionsQuery) (models.SanctionRawSearchResponseWithMatches, error)
EnrichMatch(ctx context.Context, match models.SanctionCheckMatch) ([]byte, error)
}

type SanctionCheckInboxReader interface {
Expand Down Expand Up @@ -84,6 +85,8 @@ type SanctionCheckRepository interface {
orgId, counterpartyId string, entityId []string) ([]models.SanctionCheckWhitelist, error)
CountWhitelistsForCounterpartyId(ctx context.Context, exec repositories.Executor,
orgId, counterpartyId string) (int, error)
UpdateSanctionCheckMatchPayload(ctx context.Context, exec repositories.Executor,
match models.SanctionCheckMatch, newPayload []byte) (models.SanctionCheckMatch, error)
}

type SanctionsCheckUsecaseExternalRepository interface {
Expand Down Expand Up @@ -478,6 +481,35 @@ func (uc SanctionCheckUsecase) MatchAddComment(ctx context.Context, matchId stri
return uc.repository.AddSanctionCheckMatchComment(ctx, uc.executorFactory.NewExecutor(), comment)
}

func (uc SanctionCheckUsecase) EnrichMatch(ctx context.Context, matchId string) (models.SanctionCheckMatch, error) {
if _, err := uc.enforceCanReadOrUpdateSanctionCheckMatch(ctx, matchId); err != nil {
return models.SanctionCheckMatch{}, err
}

match, err := uc.repository.GetSanctionCheckMatch(ctx, uc.executorFactory.NewExecutor(), matchId)
if err != nil {
return models.SanctionCheckMatch{}, err
}

if match.Enriched {
return models.SanctionCheckMatch{}, errors.Wrap(models.ConflictError,
"this sanction check match was already enriched")
}

newPayload, err := uc.openSanctionsProvider.EnrichMatch(ctx, match)
if err != nil {
return models.SanctionCheckMatch{}, err
}

newMatch, err := uc.repository.UpdateSanctionCheckMatchPayload(ctx,
uc.executorFactory.NewExecutor(), match, newPayload)
if err != nil {
return models.SanctionCheckMatch{}, err
}

return newMatch, nil
}

func (uc SanctionCheckUsecase) CreateFiles(ctx context.Context, creds models.Credentials,
sanctionCheckId string, files []multipart.FileHeader,
) ([]models.SanctionCheckFile, error) {
Expand Down
10 changes: 5 additions & 5 deletions usecases/sanction_check_usecase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func TestListSanctionChecksOnDecision(t *testing.T) {
exec.Mock.ExpectQuery(escapeSql(`
SELECT
sc.id, sc.decision_id, sc.status, sc.search_input, sc.search_datasets, sc.match_threshold, sc.match_limit, sc.is_manual, sc.requested_by, sc.is_partial, sc.is_archived, sc.initial_has_matches, sc.whitelisted_entities, sc.error_codes, sc.created_at, sc.updated_at,
ARRAY_AGG(ROW(scm.id,scm.sanction_check_id,scm.opensanction_entity_id,scm.status,scm.query_ids,scm.counterparty_id,scm.payload,scm.reviewed_by,scm.created_at,scm.updated_at) ORDER BY array_position(.+, scm.status), scm.payload->>'score' DESC) FILTER (WHERE scm.id IS NOT NULL) AS matches
ARRAY_AGG(ROW(scm.id,scm.sanction_check_id,scm.opensanction_entity_id,scm.status,scm.query_ids,scm.counterparty_id,scm.payload,scm.enriched,scm.reviewed_by,scm.created_at,scm.updated_at) ORDER BY array_position(.+, scm.status), scm.payload->>'score' DESC) FILTER (WHERE scm.id IS NOT NULL) AS matches
FROM sanction_checks AS sc
LEFT JOIN sanction_check_matches AS scm ON sc.id = scm.sanction_check_id
WHERE sc.decision_id = $1 AND sc.is_archived = $2
Expand Down Expand Up @@ -146,7 +146,7 @@ func TestUpdateMatchStatus(t *testing.T) {
}))

exec.Mock.
ExpectQuery(`SELECT id, sanction_check_id, opensanction_entity_id, status, query_ids, counterparty_id, payload, reviewed_by, created_at, updated_at FROM sanction_check_matches WHERE id = \$1`).
ExpectQuery(`SELECT id, sanction_check_id, opensanction_entity_id, status, query_ids, counterparty_id, payload, enriched, reviewed_by, created_at, updated_at FROM sanction_check_matches WHERE id = \$1`).
WithArgs("matchid").
WillReturnRows(pgxmock.NewRows(dbmodels.SelectSanctionCheckMatchesColumn).
AddRow(mockScmRow...),
Expand All @@ -157,20 +157,20 @@ func TestUpdateMatchStatus(t *testing.T) {
WillReturnRows(pgxmock.NewRows(dbmodels.SelectSanctionChecksColumn).
AddRow(mockScRow...),
)
exec.Mock.ExpectQuery(`SELECT id, sanction_check_id, opensanction_entity_id, status, query_ids, counterparty_id, payload, reviewed_by, created_at, updated_at FROM sanction_check_matches WHERE sanction_check_id = \$1`).
exec.Mock.ExpectQuery(`SELECT id, sanction_check_id, opensanction_entity_id, status, query_ids, counterparty_id, payload, enriched, reviewed_by, created_at, updated_at FROM sanction_check_matches WHERE sanction_check_id = \$1`).
WithArgs("sanction_check_id").
WillReturnRows(pgxmock.NewRows(dbmodels.SelectSanctionCheckMatchesColumn).
AddRow(mockScmRow...).
AddRows(mockOtherScmRows...),
)
exec.Mock.ExpectQuery(`UPDATE sanction_check_matches SET reviewed_by = \$1, status = \$2, updated_at = \$3 WHERE id = \$4 RETURNING id,sanction_check_id,opensanction_entity_id,status,query_ids,counterparty_id,payload,reviewed_by,created_at,updated_at`).
exec.Mock.ExpectQuery(`UPDATE sanction_check_matches SET reviewed_by = \$1, status = \$2, updated_at = \$3 WHERE id = \$4 RETURNING id,sanction_check_id,opensanction_entity_id,status,query_ids,counterparty_id,payload,enriched,reviewed_by,created_at,updated_at`).
WithArgs(models.UserId(""), models.SanctionMatchStatusConfirmedHit, "NOW()", "matchid").
WillReturnRows(pgxmock.NewRows(dbmodels.SelectSanctionCheckMatchesColumn).
AddRow(mockScmRow...),
)

for i := range 3 {
exec.Mock.ExpectQuery(`UPDATE sanction_check_matches SET reviewed_by = \$1, status = \$2, updated_at = \$3 WHERE id = \$4 RETURNING id,sanction_check_id,opensanction_entity_id,status,query_ids,counterparty_id,payload,reviewed_by,created_at,updated_at`).
exec.Mock.ExpectQuery(`UPDATE sanction_check_matches SET reviewed_by = \$1, status = \$2, updated_at = \$3 WHERE id = \$4 RETURNING id,sanction_check_id,opensanction_entity_id,status,query_ids,counterparty_id,payload,enriched,reviewed_by,created_at,updated_at`).
WithArgs(models.UserId(""), models.SanctionMatchStatusSkipped, "NOW()", mockOtherScms[i].Id).
WillReturnRows(pgxmock.NewRows(dbmodels.SelectSanctionCheckMatchesColumn).
AddRow(mockOtherScmRows[i]...),
Expand Down
Loading