Skip to content

Repository files navigation

Risk Scoring Engine

A configurable risk-scoring engine: arbitrary JSON in, a transparent score and decision out. Rules, field mappings, provider integrations and score thresholds are configuration — versioned, immutable once activated, and editable from a UI. No rule is hard-coded anywhere.

Backend: Go 1.22 · Gin · MongoDB · Redis Frontend: Vue 3 + TypeScript + Vite

See ARCHITECTURE.md for the design, the MongoDB data model and the execution flow.


Status — all phases implemented

Phase Scope
1 Domain, database, assessment API, policy versioning, schema inspection ✅
2 Mapping engine — extensible transform registry, type coercion ✅
3 Rule engine — recursive condition trees, 12 operators, full traces ✅
4 Score / policy engine — action semantics, clamping, flag escalation ✅
5 Integration framework — timeouts, retries, circuit breaker, SSRF guard, masking ✅
6 Webhook processing — HMAC verification, dedupe, assessment resume ✅
7 Versioning + audit trail ✅
8 Vue 3 console with visual rule builder ✅
9 Test playground with full execution trace ✅
10 Hardening — idempotency, rate limits, deadline sweeper, tenant isolation ✅

Quick start

cp .env.example .env
make up

That builds and starts MongoDB (single-node replica set), Redis, the API, the Vue console and a mock third-party provider, then seeds a demo tenant, an API key and a working policy.

Without Docker:

docker run -d -p 27017:27017 mongo:7 mongod --replSet rs0 --bind_ip_all
docker exec $(docker ps -qf ancestor=mongo:7) mongosh --eval "rs.initiate()"
go mod tidy && go run ./cmd/seed && go run ./cmd/api
cd web && npm install && npm run dev

The console

Six steps, each backed by real API calls — nothing in the UI is simulated in local state.

  1. Input schema — paste any JSON; the engine reports its field tree and suggests mappings.
  2. Field mapping — map source paths onto normalized signals, with transform pipelines.
  3. Integrations — configure providers: endpoint, auth (by secret reference), timeout, retry, request/response mapping, webhook settings.
  4. Rules — a recursive visual builder. Signals are picked from a list built out of your mappings and provider response mappings, so a typo can't silently become a rule that never matches.
  5. Score policy — thresholds with live gap/overlap checking, saved as a new immutable version.
  6. Test — run an assessment and see the score, the decision, every triggered rule with its condition traces, the score arithmetic line by line, the normalized signals with provenance, the mapping trace, and the stage-by-stage execution trace.

Test step showing a scored assessment with triggered rules and execution trace


API

/v1 routes require Authorization: Bearer <api key>. The tenant comes from the key, never the body.

POST   /v1/assessments                                   run a scoring request
GET    /v1/assessments?status=&decision=&limit=&offset=  list
GET    /v1/assessments/:id?include=trace,input,mapping   fetch with execution detail
GET    /v1/assessments/:id/trace                         execution trace
POST   /v1/test-assessment                               same pipeline, flagged as a test

POST   /v1/risk-policies                                 create a policy
GET    /v1/risk-policies
GET    /v1/risk-policies/:id
POST   /v1/risk-policies/:id/versions                    create a version (optionally activate)
GET    /v1/risk-policies/:id/versions
GET    /v1/risk-policies/:id/versions/:versionId
POST   /v1/risk-policies/:id/versions/:versionId/activate

POST   /v1/integrations                                  configure a provider
GET    /v1/integrations
GET    /v1/integrations/:id
PUT    /v1/integrations/:id

POST   /v1/webhooks/:provider                            provider callback (HMAC authenticated)
POST   /v1/schema/inspect                                discover fields in any payload

GET    /healthz  /readyz

POST /v1/assessments honours Idempotency-Key: a retry replays the stored response instead of scoring — and paying a provider for — the same request twice.

Running an assessment

curl -s -X POST localhost:8080/v1/assessments \
  -H 'Authorization: Bearer rsk_demo_local_key' \
  -H 'Idempotency-Key: order_8891' \
  -H 'Content-Type: application/json' \
  -d '{
    "policy_key": "default",
    "input": {
      "user": {"id":"123","country":"US","age":30},
      "transaction": {"amount":15000,"currency":"USD"}
    }
  }'

The response always explains itself — a bare {"score": 95} is never returned:

{
  "assessment_id": "asmt_2f1a...",
  "status": "COMPLETED",
  "score": 90,
  "decision": "DECLINE",
  "decision_reason": "score within threshold band",
  "policy": {"policy_key":"default","version":1,"hash":"9c4f2b1e77aa"},
  "rules_evaluated": 4,
  "rules_triggered": [
    {"rule_id":"high_amount","action":"ADD_SCORE","score":30,
     "reason":"Transaction amount is above 10000",
     "conditions":[{"field":"transaction.amount","op":">","expected":10000,
                    "actual":15000,"present":true,"matched":true}]},
    {"rule_id":"high_risk_country","action":"ADD_SCORE","score":20,"reason":"..."},
    {"rule_id":"third_party_fraud","action":"ADD_SCORE","score":40,"reason":"..."}
  ],
  "breakdown": {
    "entries": [
      {"rule_id":"high_amount","delta":30,"running":30},
      {"rule_id":"high_risk_country","delta":20,"running":50},
      {"rule_id":"third_party_fraud","delta":40,"running":90}
    ],
    "raw_score": 90, "score": 90, "clamped": false
  },
  "signals": [
    {"key":"customer.country","value":"US","source":"INPUT"},
    {"key":"third_party.acme.fraud_score","value":90,"source":"PROVIDER","provider":"acme"}
  ],
  "duration_ms": 34
}

That third rule fires on a signal the seeded mock provider returned, so the run above exercises the whole chain: mapping → provider call → response mapping → rules → score → decision.

The asynchronous path

Switch the seeded provider to "mode": "ASYNC" and point its path at /score-async:

POST /v1/assessments  →  202 { "status": "AWAITING_SIGNALS", "pending_providers": ["acme"] }
mock provider         →  POST /v1/webhooks/acme  (HMAC-signed, carries client_reference)
                      →  assessment resumes, rules run, decision is stored
GET /v1/assessments/:id  →  COMPLETED, with the webhook stage in the trace

Every callback is authenticated by HMAC over the raw body with a timestamp window, deduplicated by a unique index on (provider, event_id), and persisted before it is processed. Duplicate delivery is a 200 no-op. If the callback never comes, a sweeper expires the assessment rather than leaving it parked forever.


Layout

cmd/api                    service entrypoint (index bootstrap, sweeper, graceful shutdown)
cmd/seed                   demo tenant, API key, provider and worked-example policy
cmd/mockprovider           stand-in third-party provider (sync + webhook)

internal/domain            rules, signals, scoring, thresholds, versioning   <- stdlib only, no I/O
internal/port              interfaces the application layer depends on
internal/ruleeval          condition-tree evaluator (pure)
internal/app               use cases: assessment pipeline, policy, integrations, webhooks, sweeper
internal/app/mapping       transform registry and mapping engine (pure)
internal/adapter/mongox    MongoDB repositories, index bootstrap, transactions
internal/adapter/httpapi   Gin router, auth, idempotency, rate limiting, handlers
internal/adapter/providerhttp  provider client: SSRF-safe transport, retry, circuit breaker
internal/adapter/secrets   secret-reference resolution (env://, aws-sm://, vault://)
internal/adapter/sys       clock, id generator, token-bucket rate limiter
internal/pkg/jsonpath      dotted-path access and payload field discovery
internal/pkg/mask          sensitive-data redaction for logs and stored payloads
web/                       Vue 3 + TypeScript console

The layering is verifiable, not aspirational: internal/domain imports only the standard library, the MongoDB driver appears only under adapter/mongox, and Gin only under adapter/httpapi.


Tests

make test              # unit tests
make test-integration  # needs a live MongoDB replica set
make cover

Latest run: all packages pass, 28.2% overall statement coverage (dragged down by untested adapter/wiring packages — cmd/api, internal/config, internal/pkg/mask, etc. have no test files). Coverage where it matters is much higher:

Package Coverage
internal/pkg/jsonpath 94.9%
internal/ruleeval 75.0%
internal/app/mapping 69.7%
internal/adapter/sys 63.3%
internal/domain 53.6%
internal/app 34.5%

The suites concentrate on the places where a bug is expensive:

  • scoring — ADD / SUBTRACT / SET / FLAG semantics, running totals, clamping, and the rule that errored or skipped rules never contribute silently;
  • thresholds — band resolution, flag escalation that never downgrades a decision, rejection of gaps and overlaps;
  • rule engine — all 12 operators, nested AND/OR/NOT, int-versus-float comparison across JSON and config, null-versus-missing signals, and a broken rule erroring instead of scoring zero;
  • mapping — transform pipelines, currency conversion, date fallbacks, registry extensibility, declared types overriding payload types, and required fields failing closed;
  • webhooks — signature verification, tampered bodies, wrong secrets, replay outside the window;
  • SSRF — plain HTTP, embedded credentials, link-local and metadata hosts;
  • pipeline — raw input paths never reaching the signal bag, ordered traces, async parking before any rule runs;
  • integration (live Mongo) — one ACTIVE version per policy across repeated activations, transactional pointer updates, archived versions staying readable, optimistic-concurrency conflicts, webhook deduplication, and cross-tenant reads being refused.

Notes for review

  • Why a snapshot per version. policy_versions embeds mappings, rules and thresholds with a content hash. Scoring reads one document; re-reading a year-old assessment reproduces the exact configuration that produced it. The rules collection is an authoring library only.
  • Why signals are an array, not a map. Signal keys are dotted paths and BSON keys cannot contain dots.
  • Why a replica set locally. Activation archives the previous version, promotes the new one and moves the policy pointer in one transaction. A partial unique index makes "two active versions" unrepresentable regardless.
  • Fail-open vs fail-closed is configuration. Each integration carries on_error (IGNORE / DEFAULT_SIGNAL / FAIL_ASSESSMENT). Either way the failure appears in the trace.
  • Secrets. Provider configuration stores secret_ref only; values resolve at call time. API keys are stored as SHA-256 hashes.
  • ALLOW_INSECURE_PROVIDERS exists so the local mock provider can be reached over plain HTTP on a private address. It disables part of the SSRF protection and must stay false in production.

About

Risk Scoring Engine — Developed a dynamic risk-scoring engine to determine user risk scores based on configurable rules and integrated it with T3 services.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages