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

WIP: sanction check configuration and execution #803

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
Draft
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ CONVOY_API_KEY=
CONVOY_API_URL=
CONVOY_PROJECT_ID=

# If using OpenSanctions SaaS API, define the API key.
# If self-hosted, define the scheme and host of the API endpoint.
OPENSANCTIONS_API_HOST=
OPENSANCTIONS_API_KEY=

# Env variables for license retrieval
LICENSE_KEY=
KILL_IF_READ_LICENSE_ERROR=false
5 changes: 5 additions & 0 deletions api/handle_organization.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ func handlePatchOrganization(uc usecases.Usecases) func(c *gin.Context) {
organization, err := usecase.UpdateOrganization(ctx, models.UpdateOrganizationInput{
Id: organizationID,
DefaultScenarioTimezone: data.DefaultScenarioTimezone,
SanctionCheckConfig: models.OrganizationOpenSanctionsConfig{
Datasets: data.SanctionCheckDatasets,
MatchThreshold: data.SanctionCheckThreshold,
MatchLimit: data.SanctionCheckLimit,
},
})

if presentError(ctx, c, err) {
Expand Down
33 changes: 33 additions & 0 deletions api/handle_sanction_checks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package api

import (
"net/http"

"github.com/checkmarble/marble-backend/dto"
"github.com/checkmarble/marble-backend/pure_utils"
"github.com/checkmarble/marble-backend/usecases"
"github.com/gin-gonic/gin"
)

func handleListSanctionChecks(uc usecases.Usecases) func(c *gin.Context) {
return func(c *gin.Context) {
ctx := c.Request.Context()
decisionId := c.Query("decision_id")

if decisionId == "" {
c.Status(http.StatusBadRequest)
return
}

uc := usecasesWithCreds(ctx, uc).NewSanctionCheckUsecase()
sanctionChecks, err := uc.ListSanctionChecks(ctx, decisionId)

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

sanctionCheckJson := pure_utils.Map(sanctionChecks, dto.AdaptSanctionCheckDto)

c.JSON(http.StatusOK, sanctionCheckJson)
}
}
2 changes: 2 additions & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ func addRoutes(r *gin.Engine, conf Configuration, uc usecases.Usecases, auth Aut
router.PATCH("/scenario-iteration-rules/:rule_id", tom, handleUpdateRule(uc))
router.DELETE("/scenario-iteration-rules/:rule_id", tom, handleDeleteRule(uc))

router.GET("/sanction-checks", tom, handleListSanctionChecks(uc))

router.GET("/scenario-publications", tom, handleListScenarioPublications(uc))
router.POST("/scenario-publications", tom, handleCreateScenarioPublication(uc))
router.GET("/scenario-publications/preparation", tom,
Expand Down
5 changes: 5 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ func RunServer() error {
ProjectID: utils.GetEnv("CONVOY_PROJECT_ID", ""),
RateLimit: utils.GetEnv("CONVOY_RATE_LIMIT", 50),
}
openSanctionsConfig := infra.InitializeOpenSanctions(
utils.GetEnv("OPENSANCTIONS_API_HOST", ""),
utils.GetEnv("OPENSANCTIONS_API_KEY", ""),
)

seedOrgConfig := models.SeedOrgConfiguration{
CreateGlobalAdminEmail: utils.GetEnv("CREATE_GLOBAL_ADMIN_EMAIL", ""),
Expand Down Expand Up @@ -133,6 +137,7 @@ func RunServer() error {
infra.InitializeConvoyRessources(convoyConfiguration),
convoyConfiguration.RateLimit,
),
repositories.WithOpenSanctions(openSanctionsConfig),
repositories.WithClientDbConfig(clientDbConfig),
repositories.WithTracerProvider(telemetryRessources.TracerProvider),
)
Expand Down
15 changes: 14 additions & 1 deletion dto/decision_dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ type DecisionRule struct {
RuleEvaluation *ast.NodeEvaluationDto `json:"rule_evaluation,omitempty"`
}

type DecisionSanctionCheck struct {
Partial bool `json:"partial"`
Count int `json:"count"`
}

type ErrorDto struct {
Code int `json:"code"`
Message string `json:"message"`
Expand Down Expand Up @@ -128,7 +133,8 @@ type Decision struct {

type DecisionWithRules struct {
Decision
Rules []DecisionRule `json:"rules"`
Rules []DecisionRule `json:"rules"`
SanctionCheck *DecisionSanctionCheck `json:"sanction_check,omitempty"`
}

func NewDecisionDto(decision models.Decision, marbleAppHost string) Decision {
Expand Down Expand Up @@ -190,6 +196,13 @@ func NewDecisionWithRuleDto(decision models.DecisionWithRuleExecutions, marbleAp
decisionDto.Rules[i] = NewDecisionRuleDto(ruleExecution, withRuleExecution)
}

if decision.SanctionCheckExecution != nil {
decisionDto.SanctionCheck = &DecisionSanctionCheck{
Partial: decision.SanctionCheckExecution.Partial,
Count: decision.SanctionCheckExecution.Count,
}
}

return decisionDto
}

Expand Down
17 changes: 13 additions & 4 deletions dto/organization_dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ package dto
import "github.com/checkmarble/marble-backend/models"

type APIOrganization struct {
Id string `json:"id"`
Name string `json:"name"`
DefaultScenarioTimezone *string `json:"default_scenario_timezone"`
Id string `json:"id"`
Name string `json:"name"`
DefaultScenarioTimezone *string `json:"default_scenario_timezone"`
SanctionCheckDatasets []string `json:"sanction_check_datasets"`
SanctionCheckThreshold *int `json:"sanction_check_threshold"`
SanctionCheckLimit *int `json:"sanction_check_limit"`
}

func AdaptOrganizationDto(org models.Organization) APIOrganization {
return APIOrganization{
Id: org.Id,
Name: org.Name,
DefaultScenarioTimezone: org.DefaultScenarioTimezone,
SanctionCheckDatasets: org.OpenSanctionsConfig.Datasets,
SanctionCheckThreshold: org.OpenSanctionsConfig.MatchThreshold,
SanctionCheckLimit: org.OpenSanctionsConfig.MatchLimit,
}
}

Expand All @@ -22,5 +28,8 @@ type CreateOrganizationBodyDto struct {
}

type UpdateOrganizationBodyDto struct {
DefaultScenarioTimezone *string `json:"default_scenario_timezone,omitempty"`
DefaultScenarioTimezone *string `json:"default_scenario_timezone,omitempty"`
SanctionCheckDatasets []string `json:"sanction_check_datasets,omitempty"`
SanctionCheckThreshold *int `json:"sanction_check_threshold,omitempty"`
SanctionCheckLimit *int `json:"sanction_check_limit,omitempty"`
}
57 changes: 57 additions & 0 deletions dto/sanction_check_dto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package dto

import (
"encoding/json"

"github.com/checkmarble/marble-backend/models"
"github.com/checkmarble/marble-backend/pure_utils"
)

type SanctionCheckDto struct {
Id string `json:"id"`
Partial bool `json:"partial"`
Datasets []string `json:"datasets"`
Count int `json:"count"`
Request json.RawMessage `json:"request"`
Matches []SanctionCheckMatchDto `json:"matches"`
}

func AdaptSanctionCheckDto(m models.SanctionCheckExecution) SanctionCheckDto {
sanctionCheck := SanctionCheckDto{
Id: m.Id,
Partial: m.Partial,
Count: m.Count,
Datasets: make([]string, 0),
Request: m.Query.QueryPayload,
Matches: make([]SanctionCheckMatchDto, 0),
}

if len(m.Query.OrgConfig.Datasets) > 0 {
sanctionCheck.Datasets = m.Query.OrgConfig.Datasets
}
if len(m.Matches) > 0 {
sanctionCheck.Matches = pure_utils.Map(m.Matches, AdaptSanctionCheckMatchDto)
}

return sanctionCheck
}

type SanctionCheckMatchDto struct {
Id string `json:"id"`
EntityId string `json:"entity_id"`
QueryIds []string `json:"query_ids"`
Datasets []string `json:"datasets"`
Payload json.RawMessage `json:"payload"`
}

func AdaptSanctionCheckMatchDto(m models.SanctionCheckExecutionMatch) SanctionCheckMatchDto {
match := SanctionCheckMatchDto{
Id: m.Id,
EntityId: m.EntityId,
QueryIds: m.QueryIds,
Datasets: make([]string, 0),
Payload: m.Payload,
}

return match
}
69 changes: 56 additions & 13 deletions dto/scenario_iterations.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/cockroachdb/errors"

"github.com/checkmarble/marble-backend/models"
"github.com/checkmarble/marble-backend/utils"
)

// Read DTO
Expand All @@ -24,13 +25,20 @@ type ScenarioIterationDto struct {
}

type ScenarioIterationBodyDto struct {
TriggerConditionAstExpression *NodeDto `json:"trigger_condition_ast_expression"`
Rules []RuleDto `json:"rules"`
ScoreReviewThreshold *int `json:"score_review_threshold"`
ScoreBlockAndReviewThreshold *int `json:"score_block_and_review_threshold"`
ScoreRejectThreshold_deprec *int `json:"score_reject_threshold"` //nolint:tagliatelle
ScoreDeclineThreshold *int `json:"score_decline_threshold"`
Schedule string `json:"schedule"`
TriggerConditionAstExpression *NodeDto `json:"trigger_condition_ast_expression"`
Rules []RuleDto `json:"rules"`
SanctionCheckConfig *SanctionCheckConfig `json:"sanction_check_config,omitempty"`
ScoreReviewThreshold *int `json:"score_review_threshold"`
ScoreBlockAndReviewThreshold *int `json:"score_block_and_review_threshold"`
ScoreRejectThreshold_deprec *int `json:"score_reject_threshold"` //nolint:tagliatelle
ScoreDeclineThreshold *int `json:"score_decline_threshold"`
Schedule string `json:"schedule"`
}

type SanctionCheckConfig struct {
Enabled *bool `json:"enabled"`
ForceOutcome *string `json:"force_outcome,omitempty"`
ScoreModifier *int `json:"score_modifier,omitempty"`
}

func AdaptScenarioIterationWithBodyDto(si models.ScenarioIteration) (ScenarioIterationWithBodyDto, error) {
Expand All @@ -41,6 +49,7 @@ func AdaptScenarioIterationWithBodyDto(si models.ScenarioIteration) (ScenarioIte
ScoreDeclineThreshold: si.ScoreDeclineThreshold,
Schedule: si.Schedule,
Rules: make([]RuleDto, len(si.Rules)),
SanctionCheckConfig: nil,
}
for i, rule := range si.Rules {
apiRule, err := AdaptRuleDto(rule)
Expand All @@ -50,6 +59,18 @@ func AdaptScenarioIterationWithBodyDto(si models.ScenarioIteration) (ScenarioIte
}
body.Rules[i] = apiRule
}
if si.SanctionCheckConfig != nil {
body.SanctionCheckConfig = &SanctionCheckConfig{
Enabled: &si.SanctionCheckConfig.Enabled,
ForceOutcome: nil,
ScoreModifier: &si.SanctionCheckConfig.Outcome.ScoreModifier,
}

if si.SanctionCheckConfig.Outcome.ForceOutcome != models.Approve {
outcome := si.SanctionCheckConfig.Outcome.ForceOutcome.String()
body.SanctionCheckConfig.ForceOutcome = &outcome
}
}

if si.TriggerConditionAstExpression != nil {
triggerDto, err := AdaptNodeDto(*si.TriggerConditionAstExpression)
Expand All @@ -75,26 +96,47 @@ func AdaptScenarioIterationWithBodyDto(si models.ScenarioIteration) (ScenarioIte
// Update iteration DTO
type UpdateScenarioIterationBody struct {
Body struct {
TriggerConditionAstExpression *NodeDto `json:"trigger_condition_ast_expression"`
ScoreReviewThreshold *int `json:"score_review_threshold,omitempty"`
ScoreBlockAndReviewThreshold *int `json:"score_block_and_review_threshold,omitempty"`
ScoreRejectThreshold_deprec *int `json:"score_reject_threshold,omitempty"` //nolint:tagliatelle
ScoreDeclineThreshold *int `json:"score_decline_threshold,omitempty"`
Schedule *string `json:"schedule"`
TriggerConditionAstExpression *NodeDto `json:"trigger_condition_ast_expression"`
SanctionCheckConfig *SanctionCheckConfig `json:"sanction_check_config"`
ScoreReviewThreshold *int `json:"score_review_threshold,omitempty"`
ScoreBlockAndReviewThreshold *int `json:"score_block_and_review_threshold,omitempty"`
ScoreRejectThreshold_deprec *int `json:"score_reject_threshold,omitempty"` //nolint:tagliatelle
ScoreDeclineThreshold *int `json:"score_decline_threshold,omitempty"`
Schedule *string `json:"schedule"`
} `json:"body,omitempty"`
}

func AdaptUpdateScenarioIterationInput(input UpdateScenarioIterationBody, iterationId string) (models.UpdateScenarioIterationInput, error) {
updateScenarioIterationInput := models.UpdateScenarioIterationInput{
Id: iterationId,
Body: models.UpdateScenarioIterationBody{
SanctionCheckConfig: nil,
ScoreReviewThreshold: input.Body.ScoreReviewThreshold,
ScoreBlockAndReviewThreshold: input.Body.ScoreBlockAndReviewThreshold,
ScoreDeclineThreshold: input.Body.ScoreDeclineThreshold,
Schedule: input.Body.Schedule,
},
}

if input.Body.SanctionCheckConfig != nil {
updateScenarioIterationInput.Body.SanctionCheckConfig = &models.UpdateSanctionCheckConfigInput{
Enabled: input.Body.SanctionCheckConfig.Enabled,
Outcome: models.UpdateSanctionCheckOutcomeInput{
ForceOutcome: nil,
ScoreModifier: nil,
},
}

if input.Body.SanctionCheckConfig.ForceOutcome != nil {
updateScenarioIterationInput.Body.SanctionCheckConfig.Outcome.ForceOutcome = utils.Ptr(models.ForcedOutcomeFrom(
*input.Body.SanctionCheckConfig.ForceOutcome))
}
if input.Body.SanctionCheckConfig.ScoreModifier != nil {
updateScenarioIterationInput.Body.SanctionCheckConfig.Outcome.ScoreModifier =
input.Body.SanctionCheckConfig.ScoreModifier
}
}

if input.Body.ScoreDeclineThreshold == nil {
updateScenarioIterationInput.Body.ScoreDeclineThreshold = input.Body.ScoreRejectThreshold_deprec
}
Expand All @@ -119,6 +161,7 @@ type CreateScenarioIterationBody struct {
Body *struct {
TriggerConditionAstExpression *NodeDto `json:"trigger_condition_ast_expression"`
Rules []CreateRuleInputBody `json:"rules"`
SanctionCheckConfig *SanctionCheckConfig `json:"sanction_check_config,omitempty"`
ScoreReviewThreshold *int `json:"score_review_threshold,omitempty"`
ScoreBlockAndReviewThreshold *int `json:"score_block_and_review_threshold,omitempty"`
ScoreRejectThreshold_deprec *int `json:"score_reject_threshold,omitempty"` //nolint:tagliatelle
Expand Down
35 changes: 35 additions & 0 deletions infra/opensanctions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package infra

const (
OPEN_SANCTIONS_API_HOST = "https://api.opensanctions.org"
)

type OpenSanctions struct {
host string
// TODO: this is only for SaaS OpenSanctions API, we may need to abstract
// over authentication to at least offer Basic and Bearer for self-hosted.
apiKey string
}

func InitializeOpenSanctions(host, apiKey string) OpenSanctions {
return OpenSanctions{
host: host,
apiKey: apiKey,
}
}

func (os OpenSanctions) IsSelfHosted() bool {
return len(os.host) > 0
}

func (os OpenSanctions) Host() string {
if os.IsSelfHosted() {
return os.host
}

return OPEN_SANCTIONS_API_HOST
}

func (os OpenSanctions) ApiKey() string {
return os.apiKey
}
Loading
Loading