From a63eeb0080f68217194ed8a1a89096c34f9e8fe7 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 17 Jan 2022 11:46:23 +0100 Subject: [PATCH 01/12] Remove 'contract' property of accounts. --- pkg/core/account.go | 1 - pkg/ledger/ledger.go | 1 - pkg/storage/sqlstorage/accounts.go | 1 - 3 files changed, 3 deletions(-) diff --git a/pkg/core/account.go b/pkg/core/account.go index 1afa9c3f9..bb741c5f2 100644 --- a/pkg/core/account.go +++ b/pkg/core/account.go @@ -6,7 +6,6 @@ const ( type Account struct { Address string `json:"address" example:"users:001"` - Contract string `json:"contract" example:"default"` Type string `json:"type,omitempty" example:"virtual"` Balances map[string]int64 `json:"balances,omitempty" example:"COIN:100"` Volumes map[string]map[string]int64 `json:"volumes,omitempty"` diff --git a/pkg/ledger/ledger.go b/pkg/ledger/ledger.go index c207cfc10..be3397ec5 100644 --- a/pkg/ledger/ledger.go +++ b/pkg/ledger/ledger.go @@ -191,7 +191,6 @@ func (l *Ledger) FindAccounts(ctx context.Context, m ...query.QueryModifier) (qu func (l *Ledger) GetAccount(ctx context.Context, address string) (core.Account, error) { account := core.Account{ Address: address, - Contract: "default", } balances, err := l.store.AggregateBalances(ctx, address) diff --git a/pkg/storage/sqlstorage/accounts.go b/pkg/storage/sqlstorage/accounts.go index 06c596dac..995d39ec1 100644 --- a/pkg/storage/sqlstorage/accounts.go +++ b/pkg/storage/sqlstorage/accounts.go @@ -53,7 +53,6 @@ func (s *Store) FindAccounts(ctx context.Context, q query.Query) (query.Cursor, account := core.Account{ Address: address, - Contract: "default", } meta, err := s.GetMeta(ctx, "account", account.Address) From e414a13102862e84d91a0067b7e3fe922c2e231e Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 17 Jan 2022 14:06:49 +0100 Subject: [PATCH 02/12] Add rules. --- go.mod | 5 +- pkg/core/rule.go | 199 ++++++++++++++++++++++++++++++++++++++++++ pkg/core/rule_test.go | 85 ++++++++++++++++++ 3 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 pkg/core/rule.go create mode 100644 pkg/core/rule_test.go diff --git a/go.mod b/go.mod index 05f92208f..b358e7e8c 100644 --- a/go.mod +++ b/go.mod @@ -40,4 +40,7 @@ require ( go.uber.org/fx v1.16.0 ) -require github.com/go-logr/stdr v1.2.2 // indirect +require ( + github.com/davecgh/go-spew v1.1.1 + github.com/go-logr/stdr v1.2.2 // indirect +) diff --git a/pkg/core/rule.go b/pkg/core/rule.go new file mode 100644 index 000000000..21f6f0cbf --- /dev/null +++ b/pkg/core/rule.go @@ -0,0 +1,199 @@ +package core + +import ( + "errors" + "reflect" + "strings" +) + +const ( + PolicyAccept = "accept" + PolicyDeny = "deny" +) + +type EvalContext struct { + Variables map[string]interface{} + Metadata Metadata +} + +type Rule struct { + Account string + Policy string + Expr Expr +} + +type Expr interface { + Eval(EvalContext) bool +} + +type Value interface { + eval(ctx EvalContext) interface{} +} + +type or struct { + exprs []Expr +} + +func (o *or) Eval(ctx EvalContext) bool { + for _, e := range o.exprs { + if e.Eval(ctx) { + return true + } + } + return false +} + +type and struct { + exprs []Expr +} + +func (o *and) Eval(ctx EvalContext) bool { + for _, e := range o.exprs { + if !e.Eval(ctx) { + return false + } + } + return true +} + +type eq struct { + Op1 Value + Op2 Value +} + +func (o *eq) Eval(ctx EvalContext) bool { + return reflect.DeepEqual(o.Op1.eval(ctx), o.Op2.eval(ctx)) +} + +type gt struct { + Op1 Value + Op2 Value +} + +func (o *gt) Eval(ctx EvalContext) bool { + return o.Op1.eval(ctx).(int) > o.Op2.eval(ctx).(int) +} + +type constantExpr struct { + v interface{} +} + +func (e constantExpr) eval(ctx EvalContext) interface{} { + return e.v +} + +type variableExpr struct { + name string +} + +func (e variableExpr) eval(ctx EvalContext) interface{} { + return ctx.Variables[e.name] +} + +type metaExpr struct { + name string +} + +func (e metaExpr) eval(ctx EvalContext) interface{} { + return string(ctx.Metadata[e.name]) +} + +func parse(v interface{}) (expr interface{}, err error) { + switch vv := v.(type) { + case map[string]interface{}: + if len(vv) != 1 { + return nil, errors.New("malformed expression") + } + for key, vvv := range vv { + switch { + case strings.HasPrefix(key, "$"): + switch key { + case "$meta": + value, ok := vvv.(string) + if !ok { + return nil, errors.New("$meta operator invalid") + } + return &metaExpr{name: value}, nil + case "$or", "$and": + slice, ok := vvv.([]interface{}) + if !ok { + return nil, errors.New("Expected slice for operator " + key) + } + exprs := make([]Expr, 0) + for _, item := range slice { + r, err := parse(item) + if err != nil { + return nil, err + } + expr, ok := r.(Expr) + if !ok { + return nil, errors.New("unexpected value when parsing " + key) + } + exprs = append(exprs, expr) + } + switch key { + case "$and": + expr = &and{exprs: exprs} + case "$or": + expr = &or{exprs: exprs} + } + case "$eq", "$gt", "$lt": + vv, ok := vvv.([]interface{}) + if !ok { + return nil, errors.New("expected array when using $eq") + } + if len(vv) != 2 { + return nil, errors.New("expected 2 items when using $eq") + } + op1, err := parse(vv[0]) + if err != nil { + return nil, err + } + op1Value, ok := op1.(Value) + if !ok { + return nil, errors.New("op1 must be valuable") + } + op2, err := parse(vv[1]) + if err != nil { + return nil, err + } + op2Value, ok := op2.(Value) + if !ok { + return nil, errors.New("op2 must be valuable") + } + switch key { + case "$eq": + expr = &eq{ + Op1: op1Value, + Op2: op2Value, + } + case "$gt": + expr = >{ + Op1: op1Value, + Op2: op2Value, + } + } + default: + return nil, errors.New("unknown operator '" + key + "'") + } + } + } + case string: + if !strings.HasPrefix(vv, "$") { + return constantExpr{v}, nil + } + return variableExpr{vv[1:]}, nil + default: + return constantExpr{v}, nil + } + + return expr, nil +} + +func ParseRuleExpr(v map[string]interface{}) (Expr, error) { + ret, err := parse(v) + if err != nil { + return nil, err + } + return ret.(Expr), nil +} diff --git a/pkg/core/rule_test.go b/pkg/core/rule_test.go new file mode 100644 index 000000000..1ea35155f --- /dev/null +++ b/pkg/core/rule_test.go @@ -0,0 +1,85 @@ +package core + +import ( + "encoding/json" + "fmt" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestRules(t *testing.T) { + + type testCase struct { + rule map[string]interface{} + context EvalContext + shouldBeAccepted bool + } + + var tests = []testCase{ + { + rule: map[string]interface{}{ + "$or": []interface{}{ + map[string]interface{}{ + "$gt": []interface{}{ + "$balance", 0, + }, + }, + map[string]interface{}{ + "$eq": []interface{}{ + map[string]interface{}{ + "$meta": "approved", + }, + "yes", + }, + }, + }, + }, + context: EvalContext{ + Variables: map[string]interface{}{ + "balance": -10, + }, + Metadata: map[string]json.RawMessage{ + "approved": json.RawMessage("yes"), + }, + }, + shouldBeAccepted: true, + }, + { + rule: map[string]interface{}{ + "$and": []interface{}{ + map[string]interface{}{ + "$gt": []interface{}{ + "$balance", 0, + }, + }, + map[string]interface{}{ + "$eq": []interface{}{ + map[string]interface{}{ + "$meta": "approved", + }, + "yes", + }, + }, + }, + }, + context: EvalContext{ + Variables: map[string]interface{}{ + "balance": 10, + }, + Metadata: map[string]json.RawMessage{ + "approved": json.RawMessage("no"), + }, + }, + shouldBeAccepted: false, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("test%d", i), func(t *testing.T) { + r, err := ParseRuleExpr(test.rule) + assert.NoError(t, err) + assert.Equal(t, test.shouldBeAccepted, r.Eval(test.context)) + }) + } + +} From 0c598143318fb8328f6b55b9deaf36317548b298 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 17 Jan 2022 15:39:57 +0100 Subject: [PATCH 03/12] Add contract and associated storage. --- pkg/core/contract.go | 7 + pkg/core/{rule.go => expr.go} | 122 +++++++++++------- pkg/core/{rule_test.go => expr_test.go} | 0 pkg/storage/sqlstorage/contracts.go | 83 ++++++++++++ .../sqlstorage/migrations/postgresql/v001.sql | 8 ++ .../sqlstorage/migrations/sqlite/v001.sql | 10 +- pkg/storage/sqlstorage/store_test.go | 22 ++++ pkg/storage/storage.go | 9 ++ 8 files changed, 216 insertions(+), 45 deletions(-) create mode 100644 pkg/core/contract.go rename pkg/core/{rule.go => expr.go} (57%) rename pkg/core/{rule_test.go => expr_test.go} (100%) create mode 100644 pkg/storage/sqlstorage/contracts.go diff --git a/pkg/core/contract.go b/pkg/core/contract.go new file mode 100644 index 000000000..f452c94d4 --- /dev/null +++ b/pkg/core/contract.go @@ -0,0 +1,7 @@ +package core + +type Contract struct { + ID string `json:"id"` + Expr Expr `json:"expr"` + Account string `json:"account"` +} diff --git a/pkg/core/rule.go b/pkg/core/expr.go similarity index 57% rename from pkg/core/rule.go rename to pkg/core/expr.go index 21f6f0cbf..d236783c8 100644 --- a/pkg/core/rule.go +++ b/pkg/core/expr.go @@ -1,27 +1,18 @@ package core import ( + "encoding/json" "errors" + "fmt" "reflect" "strings" ) -const ( - PolicyAccept = "accept" - PolicyDeny = "deny" -) - type EvalContext struct { Variables map[string]interface{} Metadata Metadata } -type Rule struct { - Account string - Policy string - Expr Expr -} - type Expr interface { Eval(EvalContext) bool } @@ -30,12 +21,10 @@ type Value interface { eval(ctx EvalContext) interface{} } -type or struct { - exprs []Expr -} +type ExprOr []Expr -func (o *or) Eval(ctx EvalContext) bool { - for _, e := range o.exprs { +func (o ExprOr) Eval(ctx EvalContext) bool { + for _, e := range o { if e.Eval(ctx) { return true } @@ -43,12 +32,16 @@ func (o *or) Eval(ctx EvalContext) bool { return false } -type and struct { - exprs []Expr +func (e ExprOr) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]interface{}{ + "$or": []Expr(e), + }) } -func (o *and) Eval(ctx EvalContext) bool { - for _, e := range o.exprs { +type ExprAnd []Expr + +func (o ExprAnd) Eval(ctx EvalContext) bool { + for _, e := range o { if !e.Eval(ctx) { return false } @@ -56,46 +49,78 @@ func (o *and) Eval(ctx EvalContext) bool { return true } -type eq struct { +func (e ExprAnd) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]interface{}{ + "$and": []Expr(e), + }) +} + +type ExprEq struct { Op1 Value Op2 Value } -func (o *eq) Eval(ctx EvalContext) bool { +func (o *ExprEq) Eval(ctx EvalContext) bool { return reflect.DeepEqual(o.Op1.eval(ctx), o.Op2.eval(ctx)) } -type gt struct { +func (e ExprEq) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]interface{}{ + "$eq": []interface{}{e.Op1, e.Op2}, + }) +} + +type ExprGt struct { Op1 Value Op2 Value } -func (o *gt) Eval(ctx EvalContext) bool { +func (o *ExprGt) Eval(ctx EvalContext) bool { return o.Op1.eval(ctx).(int) > o.Op2.eval(ctx).(int) } -type constantExpr struct { - v interface{} +func (e ExprGt) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]interface{}{ + "$gt": []interface{}{e.Op1, e.Op2}, + }) +} + +type ConstantExpr struct { + Value interface{} } -func (e constantExpr) eval(ctx EvalContext) interface{} { - return e.v +func (e ConstantExpr) eval(ctx EvalContext) interface{} { + return e.Value } -type variableExpr struct { - name string +func (e ConstantExpr) MarshalJSON() ([]byte, error) { + return json.Marshal(e.Value) } -func (e variableExpr) eval(ctx EvalContext) interface{} { - return ctx.Variables[e.name] +type VariableExpr struct { + Name string } -type metaExpr struct { - name string +func (e VariableExpr) eval(ctx EvalContext) interface{} { + return ctx.Variables[e.Name] } -func (e metaExpr) eval(ctx EvalContext) interface{} { - return string(ctx.Metadata[e.name]) +func (e VariableExpr) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"$%s"`, e.Name)), nil +} + +type MetaExpr struct { + Name string +} + +func (e MetaExpr) eval(ctx EvalContext) interface{} { + return string(ctx.Metadata[e.Name]) +} + +func (e MetaExpr) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]interface{}{ + "$meta": e.Name, + }) } func parse(v interface{}) (expr interface{}, err error) { @@ -113,7 +138,7 @@ func parse(v interface{}) (expr interface{}, err error) { if !ok { return nil, errors.New("$meta operator invalid") } - return &metaExpr{name: value}, nil + return &MetaExpr{Name: value}, nil case "$or", "$and": slice, ok := vvv.([]interface{}) if !ok { @@ -133,9 +158,9 @@ func parse(v interface{}) (expr interface{}, err error) { } switch key { case "$and": - expr = &and{exprs: exprs} + expr = ExprAnd(exprs) case "$or": - expr = &or{exprs: exprs} + expr = ExprOr(exprs) } case "$eq", "$gt", "$lt": vv, ok := vvv.([]interface{}) @@ -163,12 +188,12 @@ func parse(v interface{}) (expr interface{}, err error) { } switch key { case "$eq": - expr = &eq{ + expr = &ExprEq{ Op1: op1Value, Op2: op2Value, } case "$gt": - expr = >{ + expr = &ExprGt{ Op1: op1Value, Op2: op2Value, } @@ -180,11 +205,11 @@ func parse(v interface{}) (expr interface{}, err error) { } case string: if !strings.HasPrefix(vv, "$") { - return constantExpr{v}, nil + return ConstantExpr{v}, nil } - return variableExpr{vv[1:]}, nil + return VariableExpr{vv[1:]}, nil default: - return constantExpr{v}, nil + return ConstantExpr{v}, nil } return expr, nil @@ -197,3 +222,12 @@ func ParseRuleExpr(v map[string]interface{}) (Expr, error) { } return ret.(Expr), nil } + +func ParseRule(data string) (Expr, error) { + m := make(map[string]interface{}) + err := json.Unmarshal([]byte(data), &m) + if err != nil { + return nil, err + } + return ParseRuleExpr(m) +} diff --git a/pkg/core/rule_test.go b/pkg/core/expr_test.go similarity index 100% rename from pkg/core/rule_test.go rename to pkg/core/expr_test.go diff --git a/pkg/storage/sqlstorage/contracts.go b/pkg/storage/sqlstorage/contracts.go new file mode 100644 index 000000000..67ebeedc6 --- /dev/null +++ b/pkg/storage/sqlstorage/contracts.go @@ -0,0 +1,83 @@ +package sqlstorage + +import ( + "context" + "encoding/json" + "github.com/huandu/go-sqlbuilder" + "github.com/numary/ledger/pkg/core" + "github.com/sirupsen/logrus" +) + +func (s *Store) FindContracts(ctx context.Context) ([]core.Contract, error) { + results := make([]core.Contract, 0) + sb := sqlbuilder.NewSelectBuilder() + sb. + Select("contract_id", "contract_expr", "contract_account"). + From(s.table("contract")) + + sqlq, args := sb.BuildWithFlavor(s.flavor) + logrus.Debugln(sqlq, args) + + rows, err := s.db.QueryContext( + ctx, + sqlq, + args..., + ) + + if err != nil { + return nil, s.error(err) + } + + for rows.Next() { + var ( + id string + exprString string + account string + ) + + err := rows.Scan(&id, &exprString, &account) + if err != nil { + return nil, err + } + + expr, err := core.ParseRule(exprString) + if err != nil { + return nil, err + } + + contract := core.Contract{ + ID: id, + Expr: expr, + Account: account, + } + results = append(results, contract) + } + + return results, nil +} + +func (s *Store) SaveContract(ctx context.Context, contract core.Contract) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return s.error(err) + } + + data, err := json.Marshal(contract.Expr) + if err != nil { + return err + } + + ib := sqlbuilder.NewInsertBuilder() + ib.InsertInto(s.table("contract")) + ib.Cols("contract_id", "contract_account", "contract_expr") + ib.Values(contract.ID, contract.Account, string(data)) + + sqlq, args := ib.BuildWithFlavor(s.flavor) + _, err = tx.ExecContext(ctx, sqlq, args...) + if err != nil { + tx.Rollback() + + return s.error(err) + } + return tx.Commit() +} diff --git a/pkg/storage/sqlstorage/migrations/postgresql/v001.sql b/pkg/storage/sqlstorage/migrations/postgresql/v001.sql index 15e54875f..2a95a8402 100644 --- a/pkg/storage/sqlstorage/migrations/postgresql/v001.sql +++ b/pkg/storage/sqlstorage/migrations/postgresql/v001.sql @@ -39,6 +39,14 @@ CREATE TABLE IF NOT EXISTS "VAR_LEDGER_NAME".metadata ( UNIQUE("meta_id") ); --statement +CREATE TABLE IF NOT EXISTS "VAR_LEDGER_NAME".contract ( + "contract_id" integer, + "contract_account" varchar, + "contract_expr" varchar, + + UNIQUE("contract_id") +) +--statement CREATE INDEX IF NOT EXISTS m_i0 ON "VAR_LEDGER_NAME".metadata ( "meta_target_type", "meta_target_id" diff --git a/pkg/storage/sqlstorage/migrations/sqlite/v001.sql b/pkg/storage/sqlstorage/migrations/sqlite/v001.sql index 9cd0de961..cb5ba2dcb 100644 --- a/pkg/storage/sqlstorage/migrations/sqlite/v001.sql +++ b/pkg/storage/sqlstorage/migrations/sqlite/v001.sql @@ -37,6 +37,14 @@ CREATE TABLE IF NOT EXISTS metadata ( UNIQUE("meta_id") ); --statement +CREATE TABLE IF NOT EXISTS contract ( + "contract_id" integer, + "contract_account" varchar, + "contract_expr" varchar, + + UNIQUE("contract_id") +) +--statement CREATE INDEX IF NOT EXISTS 'm_i0' ON "metadata" ( "meta_target_type", "meta_target_id" @@ -46,4 +54,4 @@ CREATE VIEW IF NOT EXISTS addresses AS SELECT address FROM ( SELECT source as address FROM postings GROUP BY source UNION SELECT destination as address FROM postings GROUP BY destination -) GROUP BY address; +) GROUP BY address; \ No newline at end of file diff --git a/pkg/storage/sqlstorage/store_test.go b/pkg/storage/sqlstorage/store_test.go index d176109dd..59a3eaba7 100644 --- a/pkg/storage/sqlstorage/store_test.go +++ b/pkg/storage/sqlstorage/store_test.go @@ -114,6 +114,10 @@ func TestStore(t *testing.T) { name: "GetTransaction", fn: testGetTransaction, }, + { + name: "Contracts", + fn: testContracts, + }, } { t.Run(fmt.Sprintf("%s/%s", driver.driver, tf.name), func(t *testing.T) { ledger := uuid.New() @@ -479,6 +483,24 @@ func testFindTransactions(t *testing.T, store storage.Store) { } +func testContracts(t *testing.T, store storage.Store) { + contract := core.Contract{ + ID: "1", + Expr: &core.ExprGt{ + Op1: core.VariableExpr{Name: "balance"}, + Op2: core.ConstantExpr{Value: float64(0)}, + }, + Account: "orders:*", + } + err := store.SaveContract(context.Background(), contract) + assert.NoError(t, err) + + contracts, err := store.FindContracts(context.Background()) + assert.NoError(t, err) + assert.Len(t, contracts, 1) + assert.EqualValues(t, contract, contracts[0]) +} + func testGetTransaction(t *testing.T, store storage.Store) { txs := []core.Transaction{ { diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 9fdcc188d..38c4c5817 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -43,6 +43,8 @@ type Store interface { SaveMeta(context.Context, int64, string, string, string, string, string) error GetMeta(context.Context, string, string) (core.Metadata, error) CountMeta(context.Context) (int64, error) + FindContracts(context.Context) ([]core.Contract, error) + SaveContract(ctx context.Context, contract core.Contract) error Initialize(context.Context) error Name() string Close(context.Context) error @@ -107,6 +109,13 @@ func (n noOpStore) Initialize(ctx context.Context) error { return nil } +func (n noOpStore) FindContracts(context.Context) ([]core.Contract, error) { + return nil, nil +} +func (n noOpStore) SaveContract(ctx context.Context, contract core.Contract) error { + return nil +} + func (n noOpStore) Name() string { return "noop" } From 5a79359ee2c9695b286d28c7c711bfb50da88da2 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 17 Jan 2022 16:18:15 +0100 Subject: [PATCH 04/12] Add contract at ledger level. --- go.mod | 2 +- pkg/core/contract.go | 10 ++++++++++ pkg/core/expr.go | 16 +++++++++++++++ pkg/ledger/ledger.go | 46 +++++++++++++++++++++++++++++++++++++++----- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index b358e7e8c..b056b48b6 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,6 @@ require ( ) require ( - github.com/davecgh/go-spew v1.1.1 + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect ) diff --git a/pkg/core/contract.go b/pkg/core/contract.go index f452c94d4..5a627c7d0 100644 --- a/pkg/core/contract.go +++ b/pkg/core/contract.go @@ -1,7 +1,17 @@ package core +import ( + "regexp" + "strings" +) + type Contract struct { ID string `json:"id"` Expr Expr `json:"expr"` Account string `json:"account"` } + +func (c Contract) Match(addr string) bool { + r := strings.ReplaceAll(c.Account, "*", ".*") + return regexp.MustCompile(r).Match([]byte(addr)) +} diff --git a/pkg/core/expr.go b/pkg/core/expr.go index d236783c8..468d19a58 100644 --- a/pkg/core/expr.go +++ b/pkg/core/expr.go @@ -11,6 +11,7 @@ import ( type EvalContext struct { Variables map[string]interface{} Metadata Metadata + Asset string } type Expr interface { @@ -85,6 +86,21 @@ func (e ExprGt) MarshalJSON() ([]byte, error) { }) } +type ExprGte struct { + Op1 Value + Op2 Value +} + +func (o *ExprGte) Eval(ctx EvalContext) bool { + return o.Op1.eval(ctx).(float64) >= o.Op2.eval(ctx).(float64) +} + +func (e ExprGte) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]interface{}{ + "$gte": []interface{}{e.Op1, e.Op2}, + }) +} + type ConstantExpr struct { Value interface{} } diff --git a/pkg/ledger/ledger.go b/pkg/ledger/ledger.go index be3397ec5..d4b648cae 100644 --- a/pkg/ledger/ledger.go +++ b/pkg/ledger/ledger.go @@ -16,6 +16,20 @@ const ( targetTypeTransaction = "transaction" ) +var DefaultContracts = []core.Contract{ + { + Expr: &core.ExprGte{ + Op1: core.VariableExpr{ + Name: "balance", + }, + Op2: core.ConstantExpr{ + Value: float64(0), + }, + }, + Account: "*", // world still an exception + }, +} + type Ledger struct { locker Locker name string @@ -84,6 +98,14 @@ func (l *Ledger) Commit(ctx context.Context, ts []core.Transaction) ([]core.Tran } } + contracts, err := l.store.FindContracts(ctx) + if err != nil { + return nil, err + } + if len(contracts) == 0 { // Keep default behavior + contracts = DefaultContracts + } + for addr := range rf { if addr == "world" { continue @@ -109,10 +131,24 @@ func (l *Ledger) Commit(ctx context.Context, ts []core.Transaction) ([]core.Tran } for asset := range checks { - balance, ok := balances[asset] - - if !ok || balance < checks[asset] { - return ts, NewInsufficientFundError(asset) + expectedBalance := balances[asset] - checks[asset] + for _, contract := range contracts { + if contract.Match(addr) { + meta, err := l.store.GetMeta(ctx, "account", addr) + if err != nil { + return nil, err + } + ok := contract.Expr.Eval(core.EvalContext{ + Variables: map[string]interface{}{ + "balance": float64(expectedBalance), + }, + Metadata: meta, + Asset: asset, + }) + if !ok { + return nil, NewInsufficientFundError(asset) + } + } } } } @@ -190,7 +226,7 @@ func (l *Ledger) FindAccounts(ctx context.Context, m ...query.QueryModifier) (qu func (l *Ledger) GetAccount(ctx context.Context, address string) (core.Account, error) { account := core.Account{ - Address: address, + Address: address, } balances, err := l.store.AggregateBalances(ctx, address) From a54cc482a412d44e2d588377e001796f0cfb0f48 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 17 Jan 2022 16:46:04 +0100 Subject: [PATCH 05/12] Add contracts at api level. --- pkg/api/controllers/contract_controller.go | 112 ++++++++++++++++++ pkg/api/routes/routes.go | 8 ++ pkg/ledger/ledger.go | 12 ++ .../opentelemetrytraces/storage.go | 20 ++++ pkg/storage/sqlstorage/contracts.go | 23 ++++ pkg/storage/sqlstorage/store_test.go | 7 ++ pkg/storage/storage.go | 6 + 7 files changed, 188 insertions(+) create mode 100644 pkg/api/controllers/contract_controller.go diff --git a/pkg/api/controllers/contract_controller.go b/pkg/api/controllers/contract_controller.go new file mode 100644 index 000000000..4ce157051 --- /dev/null +++ b/pkg/api/controllers/contract_controller.go @@ -0,0 +1,112 @@ +package controllers + +import ( + "github.com/gin-gonic/gin" + "github.com/numary/ledger/pkg/core" + "github.com/numary/ledger/pkg/ledger" + "github.com/pborman/uuid" + "net/http" +) + +type ContractController struct { + BaseController +} + +func NewContractController() *ContractController { + return &ContractController{} +} + +// PostContract godoc +// @Summary Post Contract +// @Description Create a new contract +// @Tags contracts +// @Schemes +// @Param ledger path string true "ledger" +// @Accept json +// @Produce json +// @Success 200 {object} controllers.BaseResponse +// @Failure 404 {object} controllers.BaseResponse +// @Router /{ledger}/contracts [post] +func (ctl *ContractController) PostContract(c *gin.Context) { + l, _ := c.Get("ledger") + + contract := &core.Contract{} + err := c.ShouldBind(contract) + if err != nil { + ctl.responseError(c, http.StatusBadRequest, err) + return + } + contract.ID = uuid.New() + + err = l.(*ledger.Ledger).SaveContract(c.Request.Context(), *contract) + if err != nil { + ctl.responseError( + c, + http.StatusInternalServerError, + err, + ) + return + } + ctl.response( + c, + http.StatusOK, + contract, + ) +} + +// DeleteContract godoc +// @Summary Delete Contract +// @Description Delete a contract +// @Tags contracts +// @Schemes +// @Param ledger path string true "ledger" +// @Param contractId path string true "contractId" +// @Accept json +// @Produce json +// @Success 200 {object} controllers.BaseResponse +// @Failure 404 {object} controllers.BaseResponse +// @Router /{ledger}/contracts/{contractId} [delete] +func (ctl *ContractController) DeleteContract(c *gin.Context) { + l, _ := c.Get("ledger") + + err := l.(*ledger.Ledger).DeleteContract(c.Request.Context(), c.Param("contractId")) + if err != nil { + ctl.responseError( + c, + http.StatusInternalServerError, + err, + ) + return + } + ctl.response( + c, + http.StatusNoContent, + nil, + ) +} + +// GetContracts godoc +// @Summary Get Contracts +// @Description Get all contracts +// @Tags contracts +// @Schemes +// @Param ledger path string true "ledger" +// @Accept json +// @Produce json +// @Success 200 {object} controllers.BaseResponse +// @Failure 404 {object} controllers.BaseResponse +// @Router /{ledger}/contracts [get] +func (ctl *ContractController) GetContracts(c *gin.Context) { + l, _ := c.Get("ledger") + + contracts, err := l.(*ledger.Ledger).FindContracts(c.Request.Context()) + if err != nil { + ctl.responseError(c, http.StatusInternalServerError, err) + return + } + ctl.response( + c, + http.StatusOK, + contracts, + ) +} diff --git a/pkg/api/routes/routes.go b/pkg/api/routes/routes.go index 94dc7ac34..b8a6541f3 100644 --- a/pkg/api/routes/routes.go +++ b/pkg/api/routes/routes.go @@ -43,6 +43,7 @@ type Routes struct { scriptController controllers.ScriptController accountController controllers.AccountController transactionController controllers.TransactionController + contractController controllers.ContractController globalMiddlewares []gin.HandlerFunc perLedgerMiddlewares []gin.HandlerFunc } @@ -59,6 +60,7 @@ func NewRoutes( scriptController controllers.ScriptController, accountController controllers.AccountController, transactionController controllers.TransactionController, + contractController controllers.ContractController, ) *Routes { return &Routes{ globalMiddlewares: globalMiddlewares, @@ -71,6 +73,7 @@ func NewRoutes( scriptController: scriptController, accountController: accountController, transactionController: transactionController, + contractController: contractController, } } @@ -109,6 +112,11 @@ func (r *Routes) Engine(cc cors.Config) *gin.Engine { ledger.GET("/accounts/:address", r.accountController.GetAccount) ledger.POST("/accounts/:address/metadata", r.accountController.PostAccountMetadata) + // ContractController + ledger.GET("/contracts", r.contractController.GetContracts) + ledger.POST("/contracts", r.contractController.PostContract) + ledger.DELETE("/contracts/:contractId", r.contractController.DeleteContract) + // ScriptController ledger.POST("/script", r.scriptController.PostScript) } diff --git a/pkg/ledger/ledger.go b/pkg/ledger/ledger.go index d4b648cae..be803407f 100644 --- a/pkg/ledger/ledger.go +++ b/pkg/ledger/ledger.go @@ -197,6 +197,18 @@ func (l *Ledger) GetTransaction(ctx context.Context, id string) (core.Transactio return tx, err } +func (l *Ledger) SaveContract(ctx context.Context, contract core.Contract) error { + return l.store.SaveContract(ctx, contract) +} + +func (l *Ledger) DeleteContract(ctx context.Context, id string) error { + return l.store.DeleteContract(ctx, id) +} + +func (l *Ledger) FindContracts(ctx context.Context) ([]core.Contract, error) { + return l.store.FindContracts(ctx) +} + func (l *Ledger) RevertTransaction(ctx context.Context, id string) error { tx, err := l.store.GetTransaction(ctx, id) if err != nil { diff --git a/pkg/opentelemetry/opentelemetrytraces/storage.go b/pkg/opentelemetry/opentelemetrytraces/storage.go index cae32bf4f..cfe29dbe9 100644 --- a/pkg/opentelemetry/opentelemetrytraces/storage.go +++ b/pkg/opentelemetry/opentelemetrytraces/storage.go @@ -128,6 +128,26 @@ func (o *openTelemetryStorage) CountMeta(ctx context.Context) (count int64, err return } +func (o *openTelemetryStorage) FindContracts(ctx context.Context) (contracts []core.Contract, err error) { + o.handle(ctx, "FindContracts", func(ctx context.Context) error { + contracts, err = o.underlying.FindContracts(ctx) + return err + }) + return +} + +func (o *openTelemetryStorage) SaveContract(ctx context.Context, contract core.Contract) error { + return o.handle(ctx, "SaveMeta", func(ctx context.Context) error { + return o.underlying.SaveContract(ctx, contract) + }) +} + +func (o *openTelemetryStorage) DeleteContract(ctx context.Context, s string) error { + return o.handle(ctx, "DeleteContract", func(ctx context.Context) error { + return o.underlying.DeleteContract(ctx, s) + }) +} + func (o *openTelemetryStorage) Initialize(ctx context.Context) error { return o.handle(ctx, "Initialize", func(ctx context.Context) error { return o.underlying.Initialize(ctx) diff --git a/pkg/storage/sqlstorage/contracts.go b/pkg/storage/sqlstorage/contracts.go index 67ebeedc6..60bf01763 100644 --- a/pkg/storage/sqlstorage/contracts.go +++ b/pkg/storage/sqlstorage/contracts.go @@ -8,6 +8,29 @@ import ( "github.com/sirupsen/logrus" ) +func (s *Store) DeleteContract(ctx context.Context, id string) error { + sb := sqlbuilder.NewDeleteBuilder() + sb.DeleteFrom(s.table("contract")).Equal("contract_id", id) + sqlq, args := sb.BuildWithFlavor(s.flavor) + logrus.Debugln(sqlq, args) + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + + _, err = tx.ExecContext(ctx, sqlq) + if err != nil { + eerr := tx.Rollback() + if eerr != nil { + panic(eerr) + } + return err + } + + return tx.Commit() +} + func (s *Store) FindContracts(ctx context.Context) ([]core.Contract, error) { results := make([]core.Contract, 0) sb := sqlbuilder.NewSelectBuilder() diff --git a/pkg/storage/sqlstorage/store_test.go b/pkg/storage/sqlstorage/store_test.go index 59a3eaba7..8bedf3c11 100644 --- a/pkg/storage/sqlstorage/store_test.go +++ b/pkg/storage/sqlstorage/store_test.go @@ -499,6 +499,13 @@ func testContracts(t *testing.T, store storage.Store) { assert.NoError(t, err) assert.Len(t, contracts, 1) assert.EqualValues(t, contract, contracts[0]) + + err = store.DeleteContract(context.Background(), contract.ID) + assert.NoError(t, err) + + contracts, err = store.FindContracts(context.Background()) + assert.NoError(t, err) + assert.Len(t, contracts, 0) } func testGetTransaction(t *testing.T, store storage.Store) { diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 38c4c5817..077a0ee0b 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -45,6 +45,7 @@ type Store interface { CountMeta(context.Context) (int64, error) FindContracts(context.Context) ([]core.Contract, error) SaveContract(ctx context.Context, contract core.Contract) error + DeleteContract(context.Context, string) error Initialize(context.Context) error Name() string Close(context.Context) error @@ -112,10 +113,15 @@ func (n noOpStore) Initialize(ctx context.Context) error { func (n noOpStore) FindContracts(context.Context) ([]core.Contract, error) { return nil, nil } + func (n noOpStore) SaveContract(ctx context.Context, contract core.Contract) error { return nil } +func (n noOpStore) DeleteContract(ctx context.Context, id string) error { + return nil +} + func (n noOpStore) Name() string { return "noop" } From 76268e31952446a2a0d292664e5fe31d9c44a178 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 17 Jan 2022 16:56:27 +0100 Subject: [PATCH 06/12] Add some operator and some testing. --- pkg/core/contract.go | 24 ++++++++++++++++++++ pkg/core/contract_test.go | 14 ++++++++++++ pkg/core/expr.go | 47 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 pkg/core/contract_test.go diff --git a/pkg/core/contract.go b/pkg/core/contract.go index 5a627c7d0..dba66a9ea 100644 --- a/pkg/core/contract.go +++ b/pkg/core/contract.go @@ -1,6 +1,7 @@ package core import ( + "encoding/json" "regexp" "strings" ) @@ -11,6 +12,29 @@ type Contract struct { Account string `json:"account"` } +func (c *Contract) UnmarshalJSON(data []byte) error { + type AuxContract Contract + type Aux struct { + AuxContract + Expr map[string]interface{} `json:"expr"` + } + aux := Aux{} + err := json.Unmarshal(data, &aux) + if err != nil { + return err + } + expr, err := ParseRuleExpr(aux.Expr) + if err != nil { + return err + } + *c = Contract{ + ID: aux.ID, + Expr: expr, + Account: aux.Account, + } + return nil +} + func (c Contract) Match(addr string) bool { r := strings.ReplaceAll(c.Account, "*", ".*") return regexp.MustCompile(r).Match([]byte(addr)) diff --git a/pkg/core/contract_test.go b/pkg/core/contract_test.go new file mode 100644 index 000000000..c7f53ad5e --- /dev/null +++ b/pkg/core/contract_test.go @@ -0,0 +1,14 @@ +package core + +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestContract_UnmarshalJSON(t *testing.T) { + contract := &Contract{} + data := `{"id": "foo", "account": "order:*", "expr": { "$gte": ["$balance", 0] }}` + err := json.Unmarshal([]byte(data), contract) + assert.NoError(t, err) +} diff --git a/pkg/core/expr.go b/pkg/core/expr.go index 468d19a58..733823b76 100644 --- a/pkg/core/expr.go +++ b/pkg/core/expr.go @@ -86,6 +86,21 @@ func (e ExprGt) MarshalJSON() ([]byte, error) { }) } +type ExprLt struct { + Op1 Value + Op2 Value +} + +func (o *ExprLt) Eval(ctx EvalContext) bool { + return o.Op1.eval(ctx).(int) > o.Op2.eval(ctx).(int) +} + +func (e ExprLt) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]interface{}{ + "$lt": []interface{}{e.Op1, e.Op2}, + }) +} + type ExprGte struct { Op1 Value Op2 Value @@ -101,6 +116,21 @@ func (e ExprGte) MarshalJSON() ([]byte, error) { }) } +type ExprLte struct { + Op1 Value + Op2 Value +} + +func (o *ExprLte) Eval(ctx EvalContext) bool { + return o.Op1.eval(ctx).(float64) >= o.Op2.eval(ctx).(float64) +} + +func (e ExprLte) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]interface{}{ + "$lte": []interface{}{e.Op1, e.Op2}, + }) +} + type ConstantExpr struct { Value interface{} } @@ -178,7 +208,7 @@ func parse(v interface{}) (expr interface{}, err error) { case "$or": expr = ExprOr(exprs) } - case "$eq", "$gt", "$lt": + case "$eq", "$gt", "$gte", "$lt", "$lte": vv, ok := vvv.([]interface{}) if !ok { return nil, errors.New("expected array when using $eq") @@ -213,6 +243,21 @@ func parse(v interface{}) (expr interface{}, err error) { Op1: op1Value, Op2: op2Value, } + case "$gte": + expr = &ExprGte{ + Op1: op1Value, + Op2: op2Value, + } + case "$lt": + expr = &ExprLt{ + Op1: op1Value, + Op2: op2Value, + } + case "$lte": + expr = &ExprLte{ + Op1: op1Value, + Op2: op2Value, + } } default: return nil, errors.New("unknown operator '" + key + "'") From 4c73497e7374e5af9607371c74c82cff227fec7f Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 17 Jan 2022 16:57:38 +0100 Subject: [PATCH 07/12] Fix DI. --- pkg/api/controllers/contract_controller.go | 4 ++-- pkg/api/controllers/controllers.go | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/api/controllers/contract_controller.go b/pkg/api/controllers/contract_controller.go index 4ce157051..e2a0e9e69 100644 --- a/pkg/api/controllers/contract_controller.go +++ b/pkg/api/controllers/contract_controller.go @@ -12,8 +12,8 @@ type ContractController struct { BaseController } -func NewContractController() *ContractController { - return &ContractController{} +func NewContractController() ContractController { + return ContractController{} } // PostContract godoc diff --git a/pkg/api/controllers/controllers.go b/pkg/api/controllers/controllers.go index b71883a07..17f2d20ac 100644 --- a/pkg/api/controllers/controllers.go +++ b/pkg/api/controllers/controllers.go @@ -36,4 +36,5 @@ var Module = fx.Options( fx.Provide(NewScriptController), fx.Provide(NewAccountController), fx.Provide(NewTransactionController), + fx.Provide(NewContractController), ) From 9fb3aebd952fb02530ac56429153940d951584a6 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 17 Jan 2022 17:02:07 +0100 Subject: [PATCH 08/12] Fix conversion issue. --- pkg/core/expr.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/core/expr.go b/pkg/core/expr.go index 733823b76..324181a00 100644 --- a/pkg/core/expr.go +++ b/pkg/core/expr.go @@ -77,7 +77,7 @@ type ExprGt struct { } func (o *ExprGt) Eval(ctx EvalContext) bool { - return o.Op1.eval(ctx).(int) > o.Op2.eval(ctx).(int) + return o.Op1.eval(ctx).(float64) > o.Op2.eval(ctx).(float64) } func (e ExprGt) MarshalJSON() ([]byte, error) { @@ -92,7 +92,7 @@ type ExprLt struct { } func (o *ExprLt) Eval(ctx EvalContext) bool { - return o.Op1.eval(ctx).(int) > o.Op2.eval(ctx).(int) + return o.Op1.eval(ctx).(float64) > o.Op2.eval(ctx).(float64) } func (e ExprLt) MarshalJSON() ([]byte, error) { From b99602cf3cb0db85cbdcef18de57ea9883eba6eb Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 17 Jan 2022 17:08:24 +0100 Subject: [PATCH 09/12] Fix contract deletion. --- pkg/storage/sqlstorage/contracts.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/storage/sqlstorage/contracts.go b/pkg/storage/sqlstorage/contracts.go index 60bf01763..cdad87ac5 100644 --- a/pkg/storage/sqlstorage/contracts.go +++ b/pkg/storage/sqlstorage/contracts.go @@ -10,7 +10,7 @@ import ( func (s *Store) DeleteContract(ctx context.Context, id string) error { sb := sqlbuilder.NewDeleteBuilder() - sb.DeleteFrom(s.table("contract")).Equal("contract_id", id) + sb.DeleteFrom(s.table("contract")).Where(sb.Equal("contract_id", id)) sqlq, args := sb.BuildWithFlavor(s.flavor) logrus.Debugln(sqlq, args) @@ -19,7 +19,7 @@ func (s *Store) DeleteContract(ctx context.Context, id string) error { return err } - _, err = tx.ExecContext(ctx, sqlq) + _, err = tx.ExecContext(ctx, sqlq, args...) if err != nil { eerr := tx.Rollback() if eerr != nil { From 38ab4629ea8ae6f79f04a65442f0067ee4b53b8d Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 17 Jan 2022 17:09:43 +0100 Subject: [PATCH 10/12] Fix some test. --- pkg/core/expr_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/core/expr_test.go b/pkg/core/expr_test.go index 1ea35155f..2dacf7282 100644 --- a/pkg/core/expr_test.go +++ b/pkg/core/expr_test.go @@ -21,7 +21,7 @@ func TestRules(t *testing.T) { "$or": []interface{}{ map[string]interface{}{ "$gt": []interface{}{ - "$balance", 0, + "$balance", float64(0), }, }, map[string]interface{}{ @@ -36,7 +36,7 @@ func TestRules(t *testing.T) { }, context: EvalContext{ Variables: map[string]interface{}{ - "balance": -10, + "balance": float64(-10), }, Metadata: map[string]json.RawMessage{ "approved": json.RawMessage("yes"), @@ -49,7 +49,7 @@ func TestRules(t *testing.T) { "$and": []interface{}{ map[string]interface{}{ "$gt": []interface{}{ - "$balance", 0, + "$balance", float64(0), }, }, map[string]interface{}{ @@ -64,7 +64,7 @@ func TestRules(t *testing.T) { }, context: EvalContext{ Variables: map[string]interface{}{ - "balance": 10, + "balance": float64(10), }, Metadata: map[string]json.RawMessage{ "approved": json.RawMessage("no"), From 43cdb297a67eb08ca8b97f5113b25bb8d374082e Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Tue, 18 Jan 2022 11:32:06 +0100 Subject: [PATCH 11/12] Replace individual contracts by a global mapping config. Contract are provided directly when creating or updating a mapping and are ordered. When contracts are processed, the first match give the decision, allow, or reject transaction. --- pkg/api/controllers/contract_controller.go | 112 ------------------ pkg/api/controllers/controllers.go | 2 +- pkg/api/controllers/mapping_controller.go | 79 ++++++++++++ pkg/api/routes/routes.go | 13 +- pkg/core/contract.go | 2 - pkg/core/mapping.go | 5 + pkg/ledger/ledger.go | 22 ++-- .../opentelemetrytraces/storage.go | 16 +-- pkg/storage/sqlstorage/contracts.go | 106 ----------------- pkg/storage/sqlstorage/mapping.go | 93 +++++++++++++++ .../sqlstorage/migrations/postgresql/v001.sql | 7 ++ .../sqlstorage/migrations/sqlite/v001.sql | 9 +- pkg/storage/sqlstorage/store_test.go | 39 +++--- pkg/storage/storage.go | 13 +- 14 files changed, 238 insertions(+), 280 deletions(-) delete mode 100644 pkg/api/controllers/contract_controller.go create mode 100644 pkg/api/controllers/mapping_controller.go create mode 100644 pkg/core/mapping.go delete mode 100644 pkg/storage/sqlstorage/contracts.go create mode 100644 pkg/storage/sqlstorage/mapping.go diff --git a/pkg/api/controllers/contract_controller.go b/pkg/api/controllers/contract_controller.go deleted file mode 100644 index e2a0e9e69..000000000 --- a/pkg/api/controllers/contract_controller.go +++ /dev/null @@ -1,112 +0,0 @@ -package controllers - -import ( - "github.com/gin-gonic/gin" - "github.com/numary/ledger/pkg/core" - "github.com/numary/ledger/pkg/ledger" - "github.com/pborman/uuid" - "net/http" -) - -type ContractController struct { - BaseController -} - -func NewContractController() ContractController { - return ContractController{} -} - -// PostContract godoc -// @Summary Post Contract -// @Description Create a new contract -// @Tags contracts -// @Schemes -// @Param ledger path string true "ledger" -// @Accept json -// @Produce json -// @Success 200 {object} controllers.BaseResponse -// @Failure 404 {object} controllers.BaseResponse -// @Router /{ledger}/contracts [post] -func (ctl *ContractController) PostContract(c *gin.Context) { - l, _ := c.Get("ledger") - - contract := &core.Contract{} - err := c.ShouldBind(contract) - if err != nil { - ctl.responseError(c, http.StatusBadRequest, err) - return - } - contract.ID = uuid.New() - - err = l.(*ledger.Ledger).SaveContract(c.Request.Context(), *contract) - if err != nil { - ctl.responseError( - c, - http.StatusInternalServerError, - err, - ) - return - } - ctl.response( - c, - http.StatusOK, - contract, - ) -} - -// DeleteContract godoc -// @Summary Delete Contract -// @Description Delete a contract -// @Tags contracts -// @Schemes -// @Param ledger path string true "ledger" -// @Param contractId path string true "contractId" -// @Accept json -// @Produce json -// @Success 200 {object} controllers.BaseResponse -// @Failure 404 {object} controllers.BaseResponse -// @Router /{ledger}/contracts/{contractId} [delete] -func (ctl *ContractController) DeleteContract(c *gin.Context) { - l, _ := c.Get("ledger") - - err := l.(*ledger.Ledger).DeleteContract(c.Request.Context(), c.Param("contractId")) - if err != nil { - ctl.responseError( - c, - http.StatusInternalServerError, - err, - ) - return - } - ctl.response( - c, - http.StatusNoContent, - nil, - ) -} - -// GetContracts godoc -// @Summary Get Contracts -// @Description Get all contracts -// @Tags contracts -// @Schemes -// @Param ledger path string true "ledger" -// @Accept json -// @Produce json -// @Success 200 {object} controllers.BaseResponse -// @Failure 404 {object} controllers.BaseResponse -// @Router /{ledger}/contracts [get] -func (ctl *ContractController) GetContracts(c *gin.Context) { - l, _ := c.Get("ledger") - - contracts, err := l.(*ledger.Ledger).FindContracts(c.Request.Context()) - if err != nil { - ctl.responseError(c, http.StatusInternalServerError, err) - return - } - ctl.response( - c, - http.StatusOK, - contracts, - ) -} diff --git a/pkg/api/controllers/controllers.go b/pkg/api/controllers/controllers.go index 17f2d20ac..00bf92f7d 100644 --- a/pkg/api/controllers/controllers.go +++ b/pkg/api/controllers/controllers.go @@ -36,5 +36,5 @@ var Module = fx.Options( fx.Provide(NewScriptController), fx.Provide(NewAccountController), fx.Provide(NewTransactionController), - fx.Provide(NewContractController), + fx.Provide(NewMappingController), ) diff --git a/pkg/api/controllers/mapping_controller.go b/pkg/api/controllers/mapping_controller.go new file mode 100644 index 000000000..b49054e7e --- /dev/null +++ b/pkg/api/controllers/mapping_controller.go @@ -0,0 +1,79 @@ +package controllers + +import ( + "github.com/gin-gonic/gin" + "github.com/numary/ledger/pkg/core" + "github.com/numary/ledger/pkg/ledger" + "net/http" +) + +type MappingController struct { + BaseController +} + +func NewMappingController() MappingController { + return MappingController{} +} + +// PutMapping godoc +// @Summary Put mapping +// @Description Update ledger mapping +// @Tags mapping +// @Schemes +// @Param ledger path string true "ledger" +// @Accept json +// @Produce json +// @Success 200 {object} controllers.BaseResponse +// @Failure 404 {object} controllers.BaseResponse +// @Router /{ledger}/mapping [put] +func (ctl *MappingController) PutMapping(c *gin.Context) { + l, _ := c.Get("ledger") + + mapping := &core.Mapping{} + err := c.ShouldBind(mapping) + if err != nil { + ctl.responseError(c, http.StatusBadRequest, err) + return + } + + err = l.(*ledger.Ledger).SaveMapping(c.Request.Context(), *mapping) + if err != nil { + ctl.responseError( + c, + http.StatusInternalServerError, + err, + ) + return + } + ctl.response( + c, + http.StatusOK, + mapping, + ) +} + +// GetMapping godoc +// @Summary Get mapping +// @Description Get ledger mapping +// @Tags contracts +// @Schemes +// @Param ledger path string true "ledger" +// @Accept json +// @Produce json +// @Success 200 {object} controllers.BaseResponse +// @Failure 404 {object} controllers.BaseResponse +// @Router /{ledger}/mapping [get] +func (ctl *MappingController) GetMapping(c *gin.Context) { + l, _ := c.Get("ledger") + + mapping, err := l.(*ledger.Ledger).LoadMapping(c.Request.Context()) + if err != nil { + ctl.responseError(c, http.StatusInternalServerError, err) + return + } + ctl.response( + c, + http.StatusOK, + mapping, + ) +} diff --git a/pkg/api/routes/routes.go b/pkg/api/routes/routes.go index b8a6541f3..85680e5f1 100644 --- a/pkg/api/routes/routes.go +++ b/pkg/api/routes/routes.go @@ -43,7 +43,7 @@ type Routes struct { scriptController controllers.ScriptController accountController controllers.AccountController transactionController controllers.TransactionController - contractController controllers.ContractController + mappingController controllers.MappingController globalMiddlewares []gin.HandlerFunc perLedgerMiddlewares []gin.HandlerFunc } @@ -60,7 +60,7 @@ func NewRoutes( scriptController controllers.ScriptController, accountController controllers.AccountController, transactionController controllers.TransactionController, - contractController controllers.ContractController, + mappingController controllers.MappingController, ) *Routes { return &Routes{ globalMiddlewares: globalMiddlewares, @@ -73,7 +73,7 @@ func NewRoutes( scriptController: scriptController, accountController: accountController, transactionController: transactionController, - contractController: contractController, + mappingController: mappingController, } } @@ -112,10 +112,9 @@ func (r *Routes) Engine(cc cors.Config) *gin.Engine { ledger.GET("/accounts/:address", r.accountController.GetAccount) ledger.POST("/accounts/:address/metadata", r.accountController.PostAccountMetadata) - // ContractController - ledger.GET("/contracts", r.contractController.GetContracts) - ledger.POST("/contracts", r.contractController.PostContract) - ledger.DELETE("/contracts/:contractId", r.contractController.DeleteContract) + // MappingController + ledger.GET("/mapping", r.mappingController.GetMapping) + ledger.PUT("/mapping", r.mappingController.PutMapping) // ScriptController ledger.POST("/script", r.scriptController.PostScript) diff --git a/pkg/core/contract.go b/pkg/core/contract.go index dba66a9ea..4d6bb4091 100644 --- a/pkg/core/contract.go +++ b/pkg/core/contract.go @@ -7,7 +7,6 @@ import ( ) type Contract struct { - ID string `json:"id"` Expr Expr `json:"expr"` Account string `json:"account"` } @@ -28,7 +27,6 @@ func (c *Contract) UnmarshalJSON(data []byte) error { return err } *c = Contract{ - ID: aux.ID, Expr: expr, Account: aux.Account, } diff --git a/pkg/core/mapping.go b/pkg/core/mapping.go new file mode 100644 index 000000000..b2599e7b9 --- /dev/null +++ b/pkg/core/mapping.go @@ -0,0 +1,5 @@ +package core + +type Mapping struct { + Contracts []Contract `json:"contracts"` +} diff --git a/pkg/ledger/ledger.go b/pkg/ledger/ledger.go index be803407f..e9ca3f8c7 100644 --- a/pkg/ledger/ledger.go +++ b/pkg/ledger/ledger.go @@ -98,13 +98,16 @@ func (l *Ledger) Commit(ctx context.Context, ts []core.Transaction) ([]core.Tran } } - contracts, err := l.store.FindContracts(ctx) + mapping, err := l.store.LoadMapping(ctx) if err != nil { return nil, err } - if len(contracts) == 0 { // Keep default behavior - contracts = DefaultContracts + + contracts := make([]core.Contract, 0) + if mapping != nil { + contracts = append(contracts, mapping.Contracts...) } + contracts = append(contracts, DefaultContracts...) for addr := range rf { if addr == "world" { @@ -148,6 +151,7 @@ func (l *Ledger) Commit(ctx context.Context, ts []core.Transaction) ([]core.Tran if !ok { return nil, NewInsufficientFundError(asset) } + break } } } @@ -197,16 +201,12 @@ func (l *Ledger) GetTransaction(ctx context.Context, id string) (core.Transactio return tx, err } -func (l *Ledger) SaveContract(ctx context.Context, contract core.Contract) error { - return l.store.SaveContract(ctx, contract) -} - -func (l *Ledger) DeleteContract(ctx context.Context, id string) error { - return l.store.DeleteContract(ctx, id) +func (l *Ledger) SaveMapping(ctx context.Context, mapping core.Mapping) error { + return l.store.SaveMapping(ctx, mapping) } -func (l *Ledger) FindContracts(ctx context.Context) ([]core.Contract, error) { - return l.store.FindContracts(ctx) +func (l *Ledger) LoadMapping(ctx context.Context) (*core.Mapping, error) { + return l.store.LoadMapping(ctx) } func (l *Ledger) RevertTransaction(ctx context.Context, id string) error { diff --git a/pkg/opentelemetry/opentelemetrytraces/storage.go b/pkg/opentelemetry/opentelemetrytraces/storage.go index cfe29dbe9..295438fd7 100644 --- a/pkg/opentelemetry/opentelemetrytraces/storage.go +++ b/pkg/opentelemetry/opentelemetrytraces/storage.go @@ -128,23 +128,17 @@ func (o *openTelemetryStorage) CountMeta(ctx context.Context) (count int64, err return } -func (o *openTelemetryStorage) FindContracts(ctx context.Context) (contracts []core.Contract, err error) { +func (o *openTelemetryStorage) LoadMapping(ctx context.Context) (m *core.Mapping, err error) { o.handle(ctx, "FindContracts", func(ctx context.Context) error { - contracts, err = o.underlying.FindContracts(ctx) + m, err = o.underlying.LoadMapping(ctx) return err }) return } -func (o *openTelemetryStorage) SaveContract(ctx context.Context, contract core.Contract) error { - return o.handle(ctx, "SaveMeta", func(ctx context.Context) error { - return o.underlying.SaveContract(ctx, contract) - }) -} - -func (o *openTelemetryStorage) DeleteContract(ctx context.Context, s string) error { - return o.handle(ctx, "DeleteContract", func(ctx context.Context) error { - return o.underlying.DeleteContract(ctx, s) +func (o *openTelemetryStorage) SaveMapping(ctx context.Context, mapping core.Mapping) error { + return o.handle(ctx, "SaveMapping", func(ctx context.Context) error { + return o.underlying.SaveMapping(ctx, mapping) }) } diff --git a/pkg/storage/sqlstorage/contracts.go b/pkg/storage/sqlstorage/contracts.go deleted file mode 100644 index cdad87ac5..000000000 --- a/pkg/storage/sqlstorage/contracts.go +++ /dev/null @@ -1,106 +0,0 @@ -package sqlstorage - -import ( - "context" - "encoding/json" - "github.com/huandu/go-sqlbuilder" - "github.com/numary/ledger/pkg/core" - "github.com/sirupsen/logrus" -) - -func (s *Store) DeleteContract(ctx context.Context, id string) error { - sb := sqlbuilder.NewDeleteBuilder() - sb.DeleteFrom(s.table("contract")).Where(sb.Equal("contract_id", id)) - sqlq, args := sb.BuildWithFlavor(s.flavor) - logrus.Debugln(sqlq, args) - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - - _, err = tx.ExecContext(ctx, sqlq, args...) - if err != nil { - eerr := tx.Rollback() - if eerr != nil { - panic(eerr) - } - return err - } - - return tx.Commit() -} - -func (s *Store) FindContracts(ctx context.Context) ([]core.Contract, error) { - results := make([]core.Contract, 0) - sb := sqlbuilder.NewSelectBuilder() - sb. - Select("contract_id", "contract_expr", "contract_account"). - From(s.table("contract")) - - sqlq, args := sb.BuildWithFlavor(s.flavor) - logrus.Debugln(sqlq, args) - - rows, err := s.db.QueryContext( - ctx, - sqlq, - args..., - ) - - if err != nil { - return nil, s.error(err) - } - - for rows.Next() { - var ( - id string - exprString string - account string - ) - - err := rows.Scan(&id, &exprString, &account) - if err != nil { - return nil, err - } - - expr, err := core.ParseRule(exprString) - if err != nil { - return nil, err - } - - contract := core.Contract{ - ID: id, - Expr: expr, - Account: account, - } - results = append(results, contract) - } - - return results, nil -} - -func (s *Store) SaveContract(ctx context.Context, contract core.Contract) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return s.error(err) - } - - data, err := json.Marshal(contract.Expr) - if err != nil { - return err - } - - ib := sqlbuilder.NewInsertBuilder() - ib.InsertInto(s.table("contract")) - ib.Cols("contract_id", "contract_account", "contract_expr") - ib.Values(contract.ID, contract.Account, string(data)) - - sqlq, args := ib.BuildWithFlavor(s.flavor) - _, err = tx.ExecContext(ctx, sqlq, args...) - if err != nil { - tx.Rollback() - - return s.error(err) - } - return tx.Commit() -} diff --git a/pkg/storage/sqlstorage/mapping.go b/pkg/storage/sqlstorage/mapping.go new file mode 100644 index 000000000..f9c31f82c --- /dev/null +++ b/pkg/storage/sqlstorage/mapping.go @@ -0,0 +1,93 @@ +package sqlstorage + +import ( + "context" + "encoding/json" + "fmt" + "github.com/huandu/go-sqlbuilder" + "github.com/numary/ledger/pkg/core" + "github.com/sirupsen/logrus" +) + +// We have only one mapping for a ledger, so hardcode the id +const mappingId = "0000" + +func (s *Store) LoadMapping(ctx context.Context) (*core.Mapping, error) { + + sb := sqlbuilder.NewSelectBuilder() + sb. + Select("mapping"). + From(s.table("mapping")) + + sqlq, args := sb.BuildWithFlavor(s.flavor) + logrus.Debugln(sqlq, args) + + rows, err := s.db.QueryContext( + ctx, + sqlq, + args..., + ) + if err != nil { + return nil, s.error(err) + } + if !rows.Next() { + return nil, nil + } + + var ( + mappingString string + ) + + err = rows.Scan(&mappingString) + if err != nil { + return nil, err + } + + m := &core.Mapping{} + err = json.Unmarshal([]byte(mappingString), m) + if err != nil { + return nil, err + } + + return m, nil +} + +func (s *Store) SaveMapping(ctx context.Context, mapping core.Mapping) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return s.error(err) + } + + data, err := json.Marshal(mapping) + if err != nil { + return err + } + + ib := sqlbuilder.NewInsertBuilder() + ib.InsertInto(s.table("mapping")) + ib.Cols("mapping_id", "mapping") + ib.Values(mappingId, string(data)) + + var ( + sqlq string + args []interface{} + ) + switch s.flavor { + case sqlbuilder.Flavor(PostgreSQL): + sqlq, args = ib.BuildWithFlavor(s.flavor) + sqlq = fmt.Sprintf("%s ON CONFLICT (mapping_id) DO UPDATE SET mapping = '%s'", sqlq, string(data)) + default: + ib.ReplaceInto(s.table("mapping")) + sqlq, args = ib.BuildWithFlavor(s.flavor) + } + + logrus.Debugln(sqlq, args) + + _, err = tx.ExecContext(ctx, sqlq, args...) + if err != nil { + tx.Rollback() + + return s.error(err) + } + return tx.Commit() +} diff --git a/pkg/storage/sqlstorage/migrations/postgresql/v001.sql b/pkg/storage/sqlstorage/migrations/postgresql/v001.sql index 2a95a8402..3730b4d10 100644 --- a/pkg/storage/sqlstorage/migrations/postgresql/v001.sql +++ b/pkg/storage/sqlstorage/migrations/postgresql/v001.sql @@ -47,6 +47,13 @@ CREATE TABLE IF NOT EXISTS "VAR_LEDGER_NAME".contract ( UNIQUE("contract_id") ) --statement +CREATE TABLE IF NOT EXISTS "VAR_LEDGER_NAME".mapping ( + "mapping_id" varchar, + "mapping" varchar, + + UNIQUE("mapping_id") +) +--statement CREATE INDEX IF NOT EXISTS m_i0 ON "VAR_LEDGER_NAME".metadata ( "meta_target_type", "meta_target_id" diff --git a/pkg/storage/sqlstorage/migrations/sqlite/v001.sql b/pkg/storage/sqlstorage/migrations/sqlite/v001.sql index cb5ba2dcb..b5d294ee0 100644 --- a/pkg/storage/sqlstorage/migrations/sqlite/v001.sql +++ b/pkg/storage/sqlstorage/migrations/sqlite/v001.sql @@ -37,12 +37,11 @@ CREATE TABLE IF NOT EXISTS metadata ( UNIQUE("meta_id") ); --statement -CREATE TABLE IF NOT EXISTS contract ( - "contract_id" integer, - "contract_account" varchar, - "contract_expr" varchar, +CREATE TABLE IF NOT EXISTS mapping ( + "mapping_id" varchar, + "mapping" varchar, - UNIQUE("contract_id") + UNIQUE("mapping_id") ) --statement CREATE INDEX IF NOT EXISTS 'm_i0' ON "metadata" ( diff --git a/pkg/storage/sqlstorage/store_test.go b/pkg/storage/sqlstorage/store_test.go index 8bedf3c11..1f03a6cc6 100644 --- a/pkg/storage/sqlstorage/store_test.go +++ b/pkg/storage/sqlstorage/store_test.go @@ -115,8 +115,8 @@ func TestStore(t *testing.T) { fn: testGetTransaction, }, { - name: "Contracts", - fn: testContracts, + name: "Mapping", + fn: testMapping, }, } { t.Run(fmt.Sprintf("%s/%s", driver.driver, tf.name), func(t *testing.T) { @@ -483,29 +483,36 @@ func testFindTransactions(t *testing.T, store storage.Store) { } -func testContracts(t *testing.T, store storage.Store) { - contract := core.Contract{ - ID: "1", - Expr: &core.ExprGt{ - Op1: core.VariableExpr{Name: "balance"}, - Op2: core.ConstantExpr{Value: float64(0)}, +func testMapping(t *testing.T, store storage.Store) { + + m := core.Mapping{ + Contracts: []core.Contract{ + { + Expr: &core.ExprGt{ + Op1: core.VariableExpr{Name: "balance"}, + Op2: core.ConstantExpr{Value: float64(0)}, + }, + Account: "orders:*", + }, }, - Account: "orders:*", } - err := store.SaveContract(context.Background(), contract) + err := store.SaveMapping(context.Background(), m) assert.NoError(t, err) - contracts, err := store.FindContracts(context.Background()) + mapping, err := store.LoadMapping(context.Background()) assert.NoError(t, err) - assert.Len(t, contracts, 1) - assert.EqualValues(t, contract, contracts[0]) + assert.Len(t, mapping.Contracts, 1) + assert.EqualValues(t, m.Contracts[0], mapping.Contracts[0]) - err = store.DeleteContract(context.Background(), contract.ID) + m2 := core.Mapping{ + Contracts: []core.Contract{}, + } + err = store.SaveMapping(context.Background(), m2) assert.NoError(t, err) - contracts, err = store.FindContracts(context.Background()) + mapping, err = store.LoadMapping(context.Background()) assert.NoError(t, err) - assert.Len(t, contracts, 0) + assert.Len(t, mapping.Contracts, 0) } func testGetTransaction(t *testing.T, store storage.Store) { diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 077a0ee0b..0e2ffd11c 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -43,9 +43,8 @@ type Store interface { SaveMeta(context.Context, int64, string, string, string, string, string) error GetMeta(context.Context, string, string) (core.Metadata, error) CountMeta(context.Context) (int64, error) - FindContracts(context.Context) ([]core.Contract, error) - SaveContract(ctx context.Context, contract core.Contract) error - DeleteContract(context.Context, string) error + LoadMapping(ctx context.Context) (*core.Mapping, error) + SaveMapping(ctx context.Context, m core.Mapping) error Initialize(context.Context) error Name() string Close(context.Context) error @@ -110,15 +109,11 @@ func (n noOpStore) Initialize(ctx context.Context) error { return nil } -func (n noOpStore) FindContracts(context.Context) ([]core.Contract, error) { +func (n noOpStore) LoadMapping(context.Context) (*core.Mapping, error) { return nil, nil } -func (n noOpStore) SaveContract(ctx context.Context, contract core.Contract) error { - return nil -} - -func (n noOpStore) DeleteContract(ctx context.Context, id string) error { +func (n noOpStore) SaveMapping(ctx context.Context, mapping core.Mapping) error { return nil } From 806431d14411cde2bfeef1cc9da4e89353e21147 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Tue, 18 Jan 2022 20:28:36 +0100 Subject: [PATCH 12/12] Fix potential security issue. --- pkg/storage/sqlstorage/mapping.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/storage/sqlstorage/mapping.go b/pkg/storage/sqlstorage/mapping.go index f9c31f82c..46c19d735 100644 --- a/pkg/storage/sqlstorage/mapping.go +++ b/pkg/storage/sqlstorage/mapping.go @@ -3,7 +3,6 @@ package sqlstorage import ( "context" "encoding/json" - "fmt" "github.com/huandu/go-sqlbuilder" "github.com/numary/ledger/pkg/core" "github.com/sirupsen/logrus" @@ -75,7 +74,7 @@ func (s *Store) SaveMapping(ctx context.Context, mapping core.Mapping) error { switch s.flavor { case sqlbuilder.Flavor(PostgreSQL): sqlq, args = ib.BuildWithFlavor(s.flavor) - sqlq = fmt.Sprintf("%s ON CONFLICT (mapping_id) DO UPDATE SET mapping = '%s'", sqlq, string(data)) + sqlq += " ON CONFLICT (mapping_id) DO UPDATE SET mapping = $2" default: ib.ReplaceInto(s.table("mapping")) sqlq, args = ib.BuildWithFlavor(s.flavor)