diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..0117a3a --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,23 @@ +name: main + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 + with: + go-version: 1.18 + - name: Run Tests + run: make test + - uses: codecov/codecov-action@v2 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d98359f --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.exe +*.exe~ +*.dll +*.so +*.dylib +policies-service +__debug_bin +*.test +*.out +bin/ +coverage.txt +coverage.html +input.yaml +rule.yaml +rule.json +.vscode \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d94c57e --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +build: + go build + +test: + go test -v -race -cover -covermode=atomic -coverprofile=coverage.txt -coverpkg=github.com/ahsayde/yapl/yapl,github.com/ahsayde/yapl/internal/operator,github.com/ahsayde/yapl/internal/parser,github.com/ahsayde/yapl/internal/renderer ./... + go tool cover -html=coverage.txt -o coverage.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..daf2fa5 --- /dev/null +++ b/README.md @@ -0,0 +1,260 @@ + +# YAPL +*YAML as Policy Language* + +[![codecov](https://codecov.io/gh/ahsayde/yapl/branch/master/graph/badge.svg?token=N5CJSZBHNF)](https://codecov.io/gh/ahsayde/yapl) +![example workflow](https://github.com/ahsayde/yapl/actions/workflows/main.yml/badge.svg) + + +- [Syntax](#syntax) +- [Usage](#usage) +- [Contexts](#contexts) +- [Operators](#operators) + + +## Syntax + + +### Selecting Resources + +The `match` and `exclude` are optional declarations used to filter resources which will be validated by the rules. + +They have the same syntax, and can be used together to include and exclude resources. + +#### Examples + +For example this code will only validate resources when `request.method` is equal `GET` + +```yaml +match: + field: request.method + operator: equal + value: GET +``` + +this code will validate all resources except when the `request.method` is equal `GET` + +```yaml +exclude: + field: request.method + operator: equal + value: GET +``` + +this code will validate all resources when `request.method` equals `GET` and `request.endpoint` not equals `/api` + +```yaml +match: + field: request.method + operator: equal + value: GET + +exclude: + field: request.endpoint + operator: equal + value: /api +``` + +the previous example can be done using only `match` statement as follow: + +```yaml +match: + and: + - field: request.method + operator: equal + value: GET + - not: + field: request.endpoint + operator: equal + value: /api +``` + +### Validating Resources + +Policies can have multiple rules. Each rule has its own condition and result object. + +rule's condition doesn't support nested conditions like `match` and `exclude`, It's only one level + +```yaml +rules: +- condition: + field: metadata.name + equal: hasPrefix + value: app + result: container name must starts with app +``` + +the `result` field could be an object, see this example + +```yaml +rules: +- condition: + field: metadata.name + equal: hasPrefix + value: app + result: + msg: container name must starts with app +``` + +#### Conditional Rule + +You can add conditional rule by adding field `when` which implements the `condition` object + +In this example, instead for writing two policies for each container type. Ypu can add two conditional rules + +```yaml +rules: +- when: + field: image + equal: equal + value: my-app-image + condition: + field: metadata.name + equal: hasPrefix + value: app + result: app container's name must starts with app + +- when: + field: image + equal: equal + value: my-db-image + condition: + field: metadata.name + equal: hasPrefix + value: db + result: db container's name must starts with db +``` + +### Context + +Contexts are a way to access information about current rule + +### Available Contexts + +| Name | Type | Description | Availability | +|--------------|-----------------|--------------------------------------------------|---------------------------------| +| [`Input`](#input-context) | `object` | The input to be checked | all fields | +| [`Params`](#params-context) | `object` | Parameters passed during evaulation | all fields | +| [`Env`](#env-context) | `object` | exported environment variable | all fields | +| [`Cond`](#condition-context) | `object` | Current condition information, | only on `rules.result` field | + + +#### `Input` Context + +Input contex allow you to access the resource object + +```yaml +rules: +- condition: + field: user.role + operator: equal + value: admin + result: user ${ .Input.user.name } doesn't has access +``` + +#### `Params` Context + +You can access parameters passed during the evaluation of input using `${ .Params. }` expression. For example + +```yaml +rules: +- condition: + field: request.body + operator: maxLength + value: ${ .Params.max_body_size } + result: request body must not exceed ${ .Params.max_body_size } +``` + +#### `Env` context + +You can access any environment variable value by using expression `${ .Env. }` + +```yaml +rules: +- condition: + field: request.body + operator: maxLength + value: ${ .Env.MAX_BODY_SIZE } + result: request body must not exceed ${ .Env.MAX_BODY_SIZE } +``` + +#### `Cond` context + +`Cond` context allow you to access all the information of the current field. + +This context is only availabe on field `rules.result`. + + +| Key | Type | Description | +|----------------------------------|-------------------|-------------------------------------------------------| +| `Cond.Field.Value` | `any` | The value of the field | +| `Cond.Field.Index` | `integer` | The index of the field if field's parent is an array | +| `Cond.Field.Parent` | `[field object]` | The parent of the field | +| `Cond.Operator` | `string` | Condition's operator | +| `Cond.Value` | `string` | Condition's value | + +Example + +```yaml +rules: + - condition: + field: metadata.labels.app + operator: hasPrefix + value: app + result: + msg: resource app label must starts with ${ .Cond.Value } but found ${ .Cond.Field.Value } + key: ${ .Field.Path } +``` + + + +## Usage + +```go +input := map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{ + "name": "bob", + "role": "member", + }, + }, +} + +params := map[string]interface{}{ + "role": "admin", +} + +raw, err := ioutil.ReadFile("policy.yaml") + if err != nil { + panic(err) + } + +policy, err := yapl.Parse(raw) +if err != nil { + panic(err) +} + +result, err := policy.Eval(input, params) +if err != nil { + panic(err) +} + +``` + + +## Operators + +| Operator | Alias | Description | Field Value | Operator Value | +|-------------|----------|---------------------------------------------------------------------------|-------------|----------------| +| `exists` | | checks whether field is exist or not | `any` | | +| `equal` | `eq` | asserts field's value equal provided value | `any` | `any` | +| `hasPrefix` | | checks whether field's value begins with prefix | `string` | `string` | +| `hasSuffix` | | checks whether field's value ends with suffix | `string` | `string` | +| `regex` | | checks whether field's value matches the provided regex | `string` | `string` | +| `minValue` | `min` | checks whether field's value is greater than or equals provided value | `number` | `number` | +| `maxValue` | `max` | checks whether field's value is less than or equals the provided value | `number` | `number` | +| `in` | | checks whether field's value in the provided value | `any` | `array` | +| `contains` | | checks whether field's value contains the provided value | `array` | `any` | +| `length` | `len` | checks whether field's value length equals the provided value | `array` | `integer` | +| `minLength` | `minlen` | checks whether field's value has minimum length equals the provided value | `array` | `integer` | +| `maxLength` | `maxlen` | checks whether field's value has maximum length equals the provided value | `array` | `integer` | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..996b258 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/ahsayde/yapl + +go 1.18 + +require ( + github.com/stretchr/testify v1.8.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5164829 --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/operator/operators.go b/internal/operator/operators.go new file mode 100644 index 0000000..8f16d59 --- /dev/null +++ b/internal/operator/operators.go @@ -0,0 +1,226 @@ +package operator + +import ( + "fmt" + "reflect" + "regexp" + "strings" +) + +var ( + floatType = reflect.TypeOf(float64(0)) +) + +type Operator interface { + Eval(val, expected interface{}) (bool, error) +} + +type EqualOperator struct{} + +func (_ *EqualOperator) Eval(val, operatorVal interface{}) (bool, error) { + return reflect.DeepEqual(val, operatorVal), nil +} + +type HasSuffixOperator struct{} + +func (_ *HasSuffixOperator) Eval(val, operatorVal interface{}) (bool, error) { + suffix, ok := operatorVal.(string) + if !ok { + return false, fmt.Errorf("suffix must be of type string, found %s", reflect.TypeOf(operatorVal).String()) + } + + value, ok := val.(string) + if !ok { + return false, nil + } + + return strings.HasSuffix(value, suffix), nil +} + +type HasPrefixOperator struct{} + +func (_ *HasPrefixOperator) Eval(val, operatorVal interface{}) (bool, error) { + prefix, ok := operatorVal.(string) + if !ok { + return false, fmt.Errorf("prefix must be of type string, found %s", reflect.TypeOf(operatorVal).String()) + } + + value, ok := val.(string) + if !ok { + return false, nil + } + + return strings.HasPrefix(value, prefix), nil +} + +type RegexOperator struct{} + +func (_ *RegexOperator) Eval(val, operatorVal interface{}) (bool, error) { + expr, ok := operatorVal.(string) + if !ok { + return false, fmt.Errorf("pattern must be of type string, found %s", reflect.TypeOf(operatorVal).String()) + } + + regex, err := regexp.Compile(expr) + if err != nil { + return false, fmt.Errorf("invalid regex expression") + } + + value, ok := val.(string) + if !ok { + return false, nil + } + + return regex.MatchString(value), nil +} + +type MaxValueOperator struct{} + +func (_ *MaxValueOperator) Eval(val, operatorVal interface{}) (bool, error) { + max, err := convertToNumber(operatorVal) + if err != nil { + return false, fmt.Errorf("max value must be of type number, but found %s", reflect.TypeOf(operatorVal).String()) + } + + value, err := convertToNumber(val) + if err != nil { + return false, fmt.Errorf("value must be of type number, but found %s", reflect.TypeOf(value).String()) + } + + return value <= max, nil +} + +type MinValueOperator struct{} + +func (_ *MinValueOperator) Eval(val, operatorVal interface{}) (bool, error) { + min, err := convertToNumber(operatorVal) + if err != nil { + return false, fmt.Errorf("min value must be of type number, but found %s", reflect.TypeOf(operatorVal).String()) + } + + value, err := convertToNumber(val) + if err != nil { + return false, fmt.Errorf("value must be of type number, but found %s", reflect.TypeOf(value).String()) + } + + return value >= min, nil +} + +type InOperator struct{} + +func (_ *InOperator) Eval(val, opval interface{}) (bool, error) { + if reflect.TypeOf(opval).Kind() != reflect.Slice { + return false, fmt.Errorf("invalid operator value, expected array found %s", reflect.TypeOf(opval).String()) + } + + arr, _ := opval.([]interface{}) + + for i := range arr { + if reflect.DeepEqual(val, arr[i]) { + return true, nil + } + } + return false, nil +} + +type ContainsOperator struct{} + +func (_ *ContainsOperator) Eval(val, opval interface{}) (bool, error) { + cval, ok := val.([]interface{}) + if !ok { + return false, fmt.Errorf("value must be of type array, found %s", reflect.TypeOf(cval).String()) + } + + for i := range cval { + if reflect.DeepEqual(opval, cval[i]) { + return true, nil + } + } + + return false, nil +} + +type LengthOperator struct{} + +func (_ *LengthOperator) Eval(val, opval interface{}) (bool, error) { + cval, ok := val.([]interface{}) + if !ok { + return false, fmt.Errorf("value must be of type array, found %s", reflect.TypeOf(cval).String()) + } + + length, ok := opval.(int) + if !ok { + return false, fmt.Errorf("length value must be of type int, found %s", reflect.TypeOf(opval).String()) + } + + return len(cval) == length, nil +} + +type MaxLengthOperator struct{} + +func (_ *MaxLengthOperator) Eval(val, opval interface{}) (bool, error) { + cval, ok := val.([]interface{}) + if !ok { + return false, fmt.Errorf("value must be of type array, found %s", reflect.TypeOf(cval).String()) + } + + maxLength, ok := opval.(int) + if !ok { + return false, fmt.Errorf("max length value must be of type int, found %s", reflect.TypeOf(opval).String()) + } + + return len(cval) <= maxLength, nil +} + +type MinLengthOperator struct{} + +func (_ *MinLengthOperator) Eval(val, opval interface{}) (bool, error) { + cval, ok := val.([]interface{}) + if !ok { + return false, fmt.Errorf("value must be of type array, found %s", reflect.TypeOf(cval).String()) + } + + minLength, ok := opval.(int) + if !ok { + return false, fmt.Errorf("min length value must be of type int, found %s", reflect.TypeOf(opval).String()) + } + + return len(cval) >= minLength, nil +} + +func GetOperator(name string) Operator { + switch name { + case "equal", "eq": + return &EqualOperator{} + case "hasPrefix": + return &HasPrefixOperator{} + case "hasSuffix": + return &HasSuffixOperator{} + case "regex": + return &RegexOperator{} + case "max", "maxValue": + return &MaxValueOperator{} + case "min", "minValue": + return &MinValueOperator{} + case "in": + return &InOperator{} + case "contains": + return &ContainsOperator{} + case "len", "length": + return &LengthOperator{} + case "maxlen", "maxLength": + return &MaxLengthOperator{} + case "minlen", "minLength": + return &MinLengthOperator{} + default: + return nil + } +} + +func convertToNumber(value interface{}) (float64, error) { + val := reflect.ValueOf(value) + if val.Type().ConvertibleTo(floatType) { + return val.Convert(floatType).Float(), nil + } + return 0, fmt.Errorf("value must be of type number, found %s", val.Type()) +} diff --git a/internal/operator/operators_test.go b/internal/operator/operators_test.go new file mode 100644 index 0000000..c85cfa5 --- /dev/null +++ b/internal/operator/operators_test.go @@ -0,0 +1,632 @@ +package operator + +import "testing" + +func TestEqualOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: 1, + inValue: 1, + result: true, + }, + { + opValue: 1, + inValue: 2, + result: false, + }, + { + opValue: true, + inValue: true, + result: true, + }, + { + opValue: true, + inValue: false, + result: false, + }, + { + opValue: 1.5, + inValue: 1.5, + result: true, + }, + { + opValue: 1.5, + inValue: 1.6, + result: false, + }, + { + opValue: "test", + inValue: "test", + result: true, + }, + { + opValue: "test", + inValue: "test2", + result: false, + }, + { + opValue: []int{1, 2, 3}, + inValue: []int{1, 2, 3}, + result: true, + }, + { + opValue: []int{1, 2, 3}, + inValue: []int{1, 2}, + result: false, + }, + { + opValue: map[string]interface{}{"a": 1, "b": 2}, + inValue: map[string]interface{}{"a": 1, "b": 2}, + result: true, + }, + { + opValue: map[string]interface{}{"a": 1, "b": 2, "c": 3}, + inValue: map[string]interface{}{"a": 1, "b": 2}, + result: false, + }, + } + + operator := GetOperator("eq") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} + +func TestHasSuffixOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: "bbb", + inValue: "aaa bbb", + result: true, + }, + { + opValue: "aaa", + inValue: "aaa bbb", + result: false, + }, + { + opValue: 1, + inValue: "aaa bbb", + result: false, + err: true, + }, + { + opValue: "aaa", + inValue: 1, + result: false, + err: true, + }, + } + + operator := GetOperator("hasSuffix") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} + +func TestHasPrefixOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: "aaa", + inValue: "aaa bbb", + result: true, + }, + { + opValue: "bbb", + inValue: "aaa bbb", + result: false, + }, + { + opValue: 1, + inValue: "aaa bbb", + result: false, + err: true, + }, + { + opValue: "aaa", + inValue: 1, + result: false, + err: true, + }, + } + + operator := GetOperator("hasPrefix") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} + +func TestMaxValueOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: 10, + inValue: 1, + result: true, + }, + { + opValue: 10, + inValue: 10, + result: true, + }, + { + opValue: 10, + inValue: 11, + result: false, + }, + { + opValue: "10", + inValue: 11, + err: true, + }, + { + opValue: 10, + inValue: "a", + err: true, + }, + } + + operator := GetOperator("max") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} + +func TestMinValueOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: 1, + inValue: 1, + result: true, + }, + { + opValue: 1, + inValue: 2, + result: true, + }, + { + opValue: 1, + inValue: 0, + result: false, + }, + { + opValue: "10", + inValue: 1, + err: true, + }, + { + opValue: 10, + inValue: "a", + err: true, + }, + } + + operator := GetOperator("min") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} + +func TestInOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: []interface{}{1, 2, 3}, + inValue: 1, + result: true, + }, + { + opValue: []interface{}{1.1, 2.2, 3.3}, + inValue: 1.1, + result: true, + }, + { + opValue: []interface{}{"a", "b", "c"}, + inValue: "a", + result: true, + }, + { + opValue: []interface{}{true, false}, + inValue: true, + result: true, + }, + { + opValue: []interface{}{ + []interface{}{1, 2}, + []interface{}{3, 4}, + []interface{}{5, 6}, + }, + inValue: []interface{}{1, 2}, + result: true, + }, + { + opValue: []interface{}{ + map[string]interface{}{"a": 1, "b": 2}, + map[string]interface{}{"a": 3, "b": 4}, + map[string]interface{}{"a": 5, "b": 6}, + }, + inValue: map[string]interface{}{"a": 1, "b": 2}, + result: true, + }, + { + opValue: []interface{}{1, 2, 3}, + inValue: 4, + result: false, + }, + { + opValue: 1, + inValue: 1, + err: true, + }, + } + + operator := GetOperator("in") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} + +func TestContainsOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: 1, + inValue: []interface{}{1, 2, 3}, + result: true, + }, + { + opValue: 1.1, + inValue: []interface{}{1.1, 2.2, 3.3}, + result: true, + }, + { + opValue: "a", + inValue: []interface{}{"a", "b", "c"}, + result: true, + }, + { + opValue: true, + inValue: []interface{}{true, false}, + result: true, + }, + { + opValue: []interface{}{1, 2}, + inValue: []interface{}{ + []interface{}{1, 2}, + []interface{}{3, 4}, + []interface{}{5, 6}, + }, + result: true, + }, + { + opValue: map[string]interface{}{"a": 1, "b": 2}, + inValue: []interface{}{ + map[string]interface{}{"a": 1, "b": 2}, + map[string]interface{}{"a": 3, "b": 4}, + map[string]interface{}{"a": 5, "b": 6}, + }, + result: true, + }, + { + opValue: 4, + inValue: []interface{}{1, 2, 3}, + result: false, + }, + { + opValue: 1, + inValue: 1, + err: true, + }, + } + + operator := GetOperator("contains") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} + +func TestLengthOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: 0, + inValue: []interface{}{}, + result: true, + }, + { + opValue: 1, + inValue: []interface{}{1}, + result: true, + }, + { + opValue: 2, + inValue: []interface{}{1.1, 1.2}, + result: true, + }, + { + opValue: 3, + inValue: []interface{}{"a", "b", "c"}, + result: true, + }, + { + opValue: 4, + inValue: []interface{}{"a", "b", "c"}, + result: false, + }, + { + inValue: 1, + opValue: 4, + err: true, + }, + { + opValue: "aaa", + inValue: []interface{}{}, + err: true, + }, + } + + operator := GetOperator("len") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} + +func TestMaxLengthOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: 1, + inValue: []interface{}{}, + result: true, + }, + { + opValue: 1, + inValue: []interface{}{1}, + result: true, + }, + { + opValue: 2, + inValue: []interface{}{1, 2, 3}, + result: false, + }, + { + inValue: 1, + opValue: 1, + err: true, + }, + { + opValue: "aaa", + inValue: []interface{}{}, + err: true, + }, + } + + operator := GetOperator("maxLength") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} + +func TestMinLengthOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: 0, + inValue: []interface{}{}, + result: true, + }, + { + opValue: 1, + inValue: []interface{}{1}, + result: true, + }, + { + opValue: 1, + inValue: []interface{}{1, 2}, + result: true, + }, + { + opValue: 2, + inValue: []interface{}{1}, + result: false, + }, + { + inValue: 1, + opValue: 1, + err: true, + }, + { + opValue: "aaa", + inValue: []interface{}{}, + err: true, + }, + } + + operator := GetOperator("minLength") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} + +func TestRegexOperator(t *testing.T) { + cases := []struct { + opValue interface{} + inValue interface{} + result bool + err bool + }{ + { + opValue: "^test$", + inValue: "test", + result: true, + }, + { + opValue: "^test$", + inValue: "aaa", + result: false, + }, + { + opValue: "?", + inValue: "test", + err: true, + }, + { + opValue: "^test$", + inValue: 1, + err: true, + }, + { + opValue: 1, + inValue: "test", + err: true, + }, + } + + operator := GetOperator("regex") + for _, test := range cases { + result, err := operator.Eval(test.inValue, test.opValue) + if err != nil { + if !test.err { + t.Error(err) + } + } else { + if result != test.result { + t.Errorf("failed") + } + } + } +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go new file mode 100644 index 0000000..c59a94c --- /dev/null +++ b/internal/parser/parser.go @@ -0,0 +1,127 @@ +package parser + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +type Type int + +const ( + Object Type = iota + Array + Scalar +) + +var ( + regex = regexp.MustCompile("^([a-zA-Z0-9]+)\\[([^(.)$]+)\\]") +) + +type Node struct { + Type Type + Path string + Index int + Value interface{} + Parent *Node + mapItems map[string]*Node + listItems map[int]*Node +} + +func Parse(in interface{}) *Node { + return lookup(in, nil, "", 0) +} + +func (n *Node) Find(key string) (*Node, error) { + nodes, err := n.find(parseKeyPath(key)) + if err != nil { + return nil, err + } + if nodes == nil { + return nil, nil + } + return nodes[0], nil +} + +func (n *Node) FindAll(key string) ([]*Node, error) { + return n.find(parseKeyPath(key)) +} + +func parseKeyPath(path string) []string { + var keys []string + parts := strings.Split(path, ".") + for _, part := range parts { + groups := regex.FindStringSubmatch(part) + if groups == nil { + keys = append(keys, part) + } else { + keys = append(keys, groups[1:]...) + } + } + return keys +} + +func (n *Node) find(path []string) ([]*Node, error) { + if len(path) == 0 { + return []*Node{n}, nil + } + if n.Type == Object { + item := n.mapItems[path[0]] + if item == nil { + return []*Node{{}}, nil + } + return item.find(path[1:]) + } else if n.Type == Array { + if path[0] == "*" { + var nodes []*Node + for i := range n.listItems { + items, err := n.listItems[i].find(path[1:]) + if err != nil { + return nil, err + } + nodes = append(nodes, items...) + } + return nodes, nil + } else { + index, err := strconv.Atoi(path[0]) + if err != nil { + return nil, fmt.Errorf("invalid index '%s'", path[0]) + } + if int(index) > len(n.listItems)-1 { + return nil, fmt.Errorf("index '%d' of field '%s' is out of range", index, n.Path) + } + return n.listItems[int(index)].find(path[1:]) + } + } + return []*Node{{}}, nil +} + +func lookup(in interface{}, parent *Node, path string, index int) *Node { + node := &Node{ + Path: strings.TrimPrefix(path, "."), + Index: index, + Parent: parent, + Value: in, + } + switch item := in.(type) { + case map[string]interface{}: + node.Type = Object + node.mapItems = make(map[string]*Node) + for key, val := range item { + p := fmt.Sprintf("%s.%s", path, key) + node.mapItems[key] = lookup(val, node, p, 0) + } + case []interface{}: + node.Type = Array + node.listItems = make(map[int]*Node) + for i, val := range item { + p := fmt.Sprintf("%s[%d]", path, i) + node.listItems[i] = lookup(val, node, p, index) + } + default: + node.Type = Scalar + node.Value = in + } + return node +} diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go new file mode 100644 index 0000000..d45e941 --- /dev/null +++ b/internal/parser/parser_test.go @@ -0,0 +1,234 @@ +package parser + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParserFind(t *testing.T) { + cases := []struct { + name string + input interface{} + key string + expected interface{} + }{ + { + name: "scalar-string", + input: map[string]interface{}{"my-key": "my-value"}, + key: "my-key", + expected: "my-value", + }, + { + name: "scalar-int", + input: map[string]interface{}{"my-key": 1}, + key: "my-key", + expected: 1, + }, + { + name: "scalar-float", + input: map[string]interface{}{"my-key": 1.5}, + key: "my-key", + expected: 1.5, + }, + { + name: "scalar-bool", + input: map[string]interface{}{"my-key": true}, + key: "my-key", + expected: true, + }, + { + name: "scalar-not-found", + input: map[string]interface{}{"my-key": true}, + key: "my-key-2", + expected: nil, + }, + { + name: "map-scalar-string", + input: map[string]interface{}{ + "parent": map[string]interface{}{ + "child": "my-value", + }, + }, + key: "parent.child", + expected: "my-value", + }, + { + name: "map-scalar-int", + input: map[string]interface{}{ + "parent": map[string]interface{}{ + "child": 1, + }, + }, + key: "parent.child", + expected: 1, + }, + { + name: "map-scalar-float", + input: map[string]interface{}{ + "parent": map[string]interface{}{ + "child": 1.5, + }, + }, + key: "parent.child", + expected: 1.5, + }, + { + name: "map-scalar-bool", + input: map[string]interface{}{ + "parent": map[string]interface{}{ + "child": true, + }, + }, + key: "parent.child", + expected: true, + }, + { + name: "map-map", + input: map[string]interface{}{ + "parent": map[string]interface{}{ + "child": map[string]interface{}{ + "child-2": "my-value", + }, + }, + }, + key: "parent.child.child-2", + expected: "my-value", + }, + { + name: "map-array", + input: map[string]interface{}{ + "parent": map[string]interface{}{ + "child": []interface{}{ + "my-value-1", + "my-value-2", + }, + }, + }, + key: "parent.child", + expected: []interface{}{"my-value-1", "my-value-2"}, + }, + { + name: "map-not-found", + input: map[string]interface{}{ + "parent": map[string]interface{}{ + "child": map[string]interface{}{}, + }, + }, + key: "parent.child.not-found", + expected: nil, + }, + } + + for i, test := range cases { + node := Parse(test.input) + field, err := node.Find(test.key) + if err != nil { + t.Error(err) + } + assert.Equal(t, test.expected, field.Value, "testcase #%d failed", i) + } +} + +func TestParserFindAll(t *testing.T) { + cases := []struct { + name string + input interface{} + key string + expected []interface{} + err bool + }{ + { + name: "with-index-0", + input: map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{ + "name": "user1", + }, + map[string]interface{}{ + "name": "user2", + }, + }, + }, + key: "users[0].name", + expected: []interface{}{"user1"}, + }, + { + name: "with-index-1", + input: map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{ + "name": "user1", + }, + map[string]interface{}{ + "name": "user2", + }, + }, + }, + key: "users[1].name", + expected: []interface{}{"user2"}, + }, + { + name: "with-asterisks", + input: map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{ + "name": "user1", + }, + map[string]interface{}{ + "name": "user2", + }, + }, + }, + key: "users[*].name", + expected: []interface{}{"user1", "user2"}, + }, + { + name: "with-invalid-key", + input: map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{ + "name": "user1", + }, + map[string]interface{}{ + "name": "user2", + }, + }, + }, + key: "users[invalid].name", + err: true, + }, + { + name: "with-out-of-index", + input: map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{ + "name": "user1", + }, + map[string]interface{}{ + "name": "user2", + }, + }, + }, + key: "users[3].name", + err: true, + }, + } + + for i, test := range cases { + node := Parse(test.input) + fields, err := node.FindAll(test.key) + if err != nil { + if test.err { + continue + } + t.Error(err) + } + + assert.Equal(t, len(test.expected), len(fields), "testcase #%d failed", i) + + for j := range fields { + assert.Equal(t, test.expected[j], fields[j].Value, "testcase #%d failed", i) + } + } +} diff --git a/internal/renderer/processor.go b/internal/renderer/processor.go new file mode 100644 index 0000000..eddd30a --- /dev/null +++ b/internal/renderer/processor.go @@ -0,0 +1,92 @@ +package renderer + +import ( + "fmt" + "math" + "strings" + "time" +) + +var ( + processors = map[string]interface{}{ + // string processors + "split": strings.Split, + "lower": strings.ToLower, + "upper": strings.ToUpper, + "title": strings.Title, + "join": strings.Join, + "trim": strings.Trim, + "trimLeft": strings.TrimLeft, + "trimRigh": strings.TrimRight, + "trimPrefix": strings.TrimPrefix, + "trimSuffix": strings.TrimSuffix, + "replace": strings.Replace, + "replaceAll": strings.ReplaceAll, + // math processors + "round": math.Round, + "ceil": math.Ceil, + "abs": math.Abs, + "floor": math.Floor, + "max": math.Max, + "min": math.Min, + // date & time processors + "date": date, + "now": time.Now, + "year": year, + "month": month, + "weekday": weekday, + "day": day, + "hour": hour, + "minute": minute, + "second": second, + // types processors + "bool": boolean, + } +) + +func date(s string) (time.Time, error) { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, err + } + return t, nil +} + +func year(t time.Time) int { + return t.Year() +} + +func month(t time.Time) int { + return int(t.Month()) +} + +func weekday(t time.Time) int { + return int(t.Weekday()) +} + +func day(t time.Time) int { + return t.Day() +} + +func hour(t time.Time) int { + return t.Hour() +} + +func minute(t time.Time) int { + return t.Minute() +} + +func second(t time.Time) int { + return t.Second() +} + +func boolean(s string) (bool, error) { + switch s { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("cannot convert %s to boolean", s) + } +} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go new file mode 100644 index 0000000..266e66e --- /dev/null +++ b/internal/renderer/renderer.go @@ -0,0 +1,86 @@ +package renderer + +import ( + "bytes" + "text/template" + + "gopkg.in/yaml.v3" +) + +const ( + leftDelim = "${" + rightDelim = "}" +) + +var ( + gtmpl = template.New(""). + Funcs(processors). + Option("missingkey=error"). + Delims(leftDelim, rightDelim) +) + +type Renderable struct { + value interface{} + tmpl *template.Template +} + +func (r *Renderable) init(value interface{}) error { + var err error + r.value = value + + switch v := value.(type) { + case string: + r.tmpl, err = gtmpl.New("").Parse(v) + if err != nil { + return err + } + case map[string]interface{}, []interface{}, interface{}: + raw, err := yaml.Marshal(r.value) + if err != nil { + return err + } + r.tmpl, err = gtmpl.New("").Parse(string(raw)) + if err != nil { + return err + } + } + + return nil +} + +func newRenderable(value interface{}) (*Renderable, error) { + r := &Renderable{} + err := r.init(value) + if err != nil { + return nil, err + } + return r, nil +} + +func (r *Renderable) UnmarshalYAML(n *yaml.Node) error { + var value interface{} + err := n.Decode(&value) + if err != nil { + return err + } + return r.init(value) +} + +func (r *Renderable) Render(ctx interface{}) (interface{}, error) { + if r.tmpl == nil { + return r.value, nil + } + + var buff bytes.Buffer + err := r.tmpl.Execute(&buff, ctx) + if err != nil { + return nil, err + } + + var value interface{} + err = yaml.Unmarshal(buff.Bytes(), &value) + if err != nil { + return nil, err + } + return value, nil +} diff --git a/internal/renderer/renderer_test.go b/internal/renderer/renderer_test.go new file mode 100644 index 0000000..a2c2b9a --- /dev/null +++ b/internal/renderer/renderer_test.go @@ -0,0 +1,89 @@ +package renderer + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParser(t *testing.T) { + cases := []struct { + name string + expr interface{} + ctx map[string]interface{} + value interface{} + }{ + { + name: "string", + expr: "${ .param1 }", + ctx: map[string]interface{}{ + "param1": "test1", + }, + value: "test1", + }, + { + name: "boolean", + expr: "${ .param1 }", + ctx: map[string]interface{}{ + "param1": true, + }, + value: true, + }, + { + name: "integer", + expr: "${ .param1 }", + ctx: map[string]interface{}{ + "param1": 1, + }, + value: 1, + }, + { + name: "float", + expr: "${ .param1 }", + ctx: map[string]interface{}{ + "param1": 1.5, + }, + value: 1.5, + }, + { + name: "array", + expr: []interface{}{ + "${.param1}", + "${.param2}", + }, + ctx: map[string]interface{}{ + "param1": "test1", + "param2": "test2", + }, + value: []interface{}{"test1", "test2"}, + }, + { + name: "object", + expr: map[string]interface{}{ + "key1": "${.param1}", + "key2": "${.param2}", + }, + ctx: map[string]interface{}{ + "param1": "test1", + "param2": "test2", + }, + value: map[string]interface{}{ + "key1": "test1", + "key2": "test2", + }, + }, + } + + for i, test := range cases { + r, err := newRenderable(test.expr) + if err != nil { + t.Fatal(err) + } + + val, err := r.Render(test.ctx) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, test.value, val, "testcase #%d failed", i) + } +} diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..7d50f76 --- /dev/null +++ b/schema.json @@ -0,0 +1,136 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "any": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + }, + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "boolean" + } + ] + }, + "condition": { + "type": "object", + "required": [ + "operator", + "value" + ], + "anyOf": [ + { + "required": [ + "field" + ] + }, + { + "required": [ + "expr" + ] + } + ], + "properties": { + "field": { + "type": "string" + }, + "expr": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/any" + } + } + }, + "nested_condition": { + "oneOf": [ + { + "type": "object", + "required": [ + "and" + ], + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/definitions/nested_condition" + } + } + } + }, + { + "type": "object", + "required": [ + "or" + ], + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/definitions/nested_condition" + } + } + } + }, + { + "type": "object", + "required": [ + "not" + ], + "properties": { + "not": { + "$ref": "#/definitions/nested_condition" + } + } + }, + { + "$ref": "#/definitions/condition" + } + ] + } + }, + "properties": { + "match": { + "$ref": "#/definitions/nested_condition" + }, + "exclude": { + "$ref": "#/definitions/nested_condition" + }, + "rules": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "condition", + "result" + ], + "properties": { + "when": { + "$ref": "#/definitions/nested_condition" + }, + "condition": { + "$ref": "#/definitions/condition" + }, + "result": { + "$ref": "#/definitions/any" + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/test_test.go b/tests/test_test.go new file mode 100644 index 0000000..05271d8 --- /dev/null +++ b/tests/test_test.go @@ -0,0 +1,85 @@ +package test + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "path/filepath" + "testing" + + "github.com/ahsayde/yapl/yapl" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +type TestSuite struct { + ID string `yaml:"id"` + Policy map[string]interface{} `yaml:"policy"` + Testcases []TestCase `yaml:"tests"` +} + +type TestCase struct { + ID string `yaml:"id"` + Input map[string]interface{} `yaml:"input"` + Params map[string]interface{} `yaml:"params"` + Result *yapl.Result `yaml:"result"` + Errors []string `yaml:"errors"` +} + +func readTestFile(path string) ([]TestSuite, error) { + var testsuites []TestSuite + + raw, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + + for { + var testsuite TestSuite + if err := decoder.Decode(&testsuite); err != nil { + if err != io.EOF { + return nil, err + } + break + } + testsuites = append(testsuites, testsuite) + } + + return testsuites, nil +} + +func Test(t *testing.T) { + files, err := filepath.Glob("./testcases/*yaml") + if err != nil { + t.Fatalf(err.Error()) + } + var testsuites []TestSuite + for i := range files { + tests, err := readTestFile(files[i]) + if err != nil { + t.Fatal(err.Error()) + } + testsuites = append(testsuites, tests...) + } + for _, testsuite := range testsuites { + for _, testcase := range testsuite.Testcases { + testID := fmt.Sprintf("%s.%s", testsuite.ID, testcase.ID) + raw, err := yaml.Marshal(testsuite.Policy) + if err != nil { + t.Fatal(err) + } + policy, err := yapl.Parse(raw) + if err != nil { + t.Fatal(err) + } + result, err := policy.Eval(testcase.Input, testcase.Params) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, testcase.Result, result, "testcase: %s failed", testID) + } + } +} diff --git a/tests/testcases/check.yaml b/tests/testcases/check.yaml new file mode 100644 index 0000000..bd28e85 --- /dev/null +++ b/tests/testcases/check.yaml @@ -0,0 +1,106 @@ +id: check-statment +policy: + rules: + - condition: + field: metadata.namespace + operator: equal + value: default + result: namespace must be default +tests: +- id: field-passing-check + input: + metadata: + namespace: default + result: + passed: true +- id: field-failing-check + input: + metadata: + namespace: test + result: + failed: true + errors: + - namespace must be default +- id: field-failing-check-2 + input: + metadata: + result: + failed: true + errors: + - namespace must be default + +--- + +id: check-statment-2 +policy: + rules: + - when: + field: metadata.name + operator: equal + value: app + condition: + field: metadata.namespace + operator: equal + value: default + result: namespace must be default +tests: +- id: field-passing-check + input: + metadata: + name: app + namespace: default + result: + passed: true +- id: field-passing-check + input: + metadata: + name: test + namespace: default + result: + passed: true +- id: field-passing-check + input: + metadata: + name: app + namespace: test + result: + failed: true + errors: + - namespace must be default + + +# id: check-statment-2 +# policy: +# rules: +# - condition: +# expr: default +# operator: equal +# value: default +# result: namespace must be default +# tests: +# - id: field-passing-check +# input: +# metadata: +# namespace: default +# result: +# passed: true + +# --- + +# id: check-statment-2 +# policy: +# rules: +# - condition: +# expr: ${ .Params.test | upper } +# operator: equal +# value: DEFAULT +# result: namespace must be default +# tests: +# - id: field-passing-check +# input: +# metadata: +# namespace: default +# params: +# test: default +# result: +# passed: true diff --git a/tests/testcases/exclude.yaml b/tests/testcases/exclude.yaml new file mode 100644 index 0000000..f249e7f --- /dev/null +++ b/tests/testcases/exclude.yaml @@ -0,0 +1,150 @@ +id: exclude-statment +policy: + exclude: + field: metadata.namespace + operator: equal + value: default + rules: + - condition: + expr: true + operator: equal + value: true + result: test +tests: +- id: test-excluding + input: + metadata: + namespace: default + result: + ignored: true +- id: test-not-excluding + input: + metadata: + namespace: test + result: + passed: true + +--- + +id: exclude-statment-with-not +policy: + exclude: + not: + field: metadata.namespace + operator: equal + value: default + rules: + - condition: + expr: true + operator: equal + value: true + result: test +tests: +- id: test-excluding + input: + metadata: + namespace: default + result: + passed: true +- id: test-not-excluding + input: + metadata: + namespace: test + result: + ignored: true + +--- + +id: exclude-statment-with-and +policy: + exclude: + and: + - field: metadata.name + operator: equal + value: app + - field: metadata.namespace + operator: equal + value: default + rules: + - condition: + expr: true + operator: equal + value: true + result: test +tests: +- id: test-excluding-1 + input: + metadata: + name: app + namespace: default + result: + ignored: true +- id: test-not-exclude-1 + input: + metadata: + name: app + namespace: test + result: + passed: true +- id: test-not-exclude-2 + input: + metadata: + name: test + namespace: default + result: + passed: true +- id: test-not-excluding-3 + input: + metadata: + name: test + namespace: test + result: + passed: true + +--- + +id: match-exclude-with-or +policy: + exclude: + or: + - field: metadata.name + operator: equal + value: app + - field: metadata.namespace + operator: equal + value: default + rules: + - condition: + expr: true + operator: equal + value: true + result: test +tests: +- id: test-excluding-1 + input: + metadata: + name: app + namespace: default + result: + ignored: true +- id: test-excluding-2 + input: + metadata: + name: app + namespace: test + result: + ignored: true +- id: test-excluding-3 + input: + metadata: + name: test + namespace: default + result: + ignored: true +- id: test-not-exclude-1 + input: + metadata: + name: test + namespace: test + result: + passed: true diff --git a/tests/testcases/match.yaml b/tests/testcases/match.yaml new file mode 100644 index 0000000..0a53849 --- /dev/null +++ b/tests/testcases/match.yaml @@ -0,0 +1,150 @@ +id: match-statment +policy: + match: + field: metadata.namespace + operator: equal + value: default + rules: + - condition: + expr: true + operator: equal + value: true + result: test +tests: +- id: test-matching + input: + metadata: + namespace: default + result: + passed: true +- id: test-unmatching + input: + metadata: + namespace: test + result: + ignored: true + +--- + +id: match-statment-with-not +policy: + match: + not: + field: metadata.namespace + operator: equal + value: default + rules: + - condition: + expr: true + operator: equal + value: true + result: test +tests: +- id: test-matching + input: + metadata: + namespace: test + result: + passed: true +- id: test-unmatching + input: + metadata: + namespace: default + result: + ignored: true + +--- + +id: match-statment-with-and +policy: + match: + and: + - field: metadata.name + operator: equal + value: app + - field: metadata.namespace + operator: equal + value: default + rules: + - condition: + expr: true + operator: equal + value: true + result: test +tests: +- id: test-matching-1 + input: + metadata: + name: app + namespace: default + result: + passed: true +- id: test-unmatching-1 + input: + metadata: + name: app + namespace: test + result: + ignored: true +- id: test-unmatching-2 + input: + metadata: + name: test + namespace: default + result: + ignored: true +- id: test-unmatching-3 + input: + metadata: + name: test + namespace: test + result: + ignored: true + +--- + +id: match-statment-with-or +policy: + match: + or: + - field: metadata.name + operator: equal + value: app + - field: metadata.namespace + operator: equal + value: default + rules: + - condition: + expr: true + operator: equal + value: true + result: test +tests: +- id: test-matching-1 + input: + metadata: + name: app + namespace: default + result: + passed: true +- id: test-matching-2 + input: + metadata: + name: app + namespace: test + result: + passed: true +- id: test-matching-3 + input: + metadata: + name: test + namespace: default + result: + passed: true +- id: test-unmatching-1 + input: + metadata: + name: test + namespace: test + result: + ignored: true diff --git a/yapl/condition.go b/yapl/condition.go new file mode 100644 index 0000000..8b3bab1 --- /dev/null +++ b/yapl/condition.go @@ -0,0 +1,242 @@ +package yapl + +import ( + "fmt" + + "github.com/ahsayde/yapl/internal/operator" + "github.com/ahsayde/yapl/internal/parser" + "github.com/ahsayde/yapl/internal/renderer" +) + +type renderedCondition struct { + Field string + Operator string + Value interface{} + Expr *renderer.Renderable +} + +type ConditionResult struct { + Field *parser.Node + Operator string + Expr interface{} + Value interface{} +} + +type Condition struct { + Field *renderer.Renderable `json:"field,omitempty" yaml:"field,omitempty"` + Expr *renderer.Renderable `json:"expr,omitempty" yaml:"expr,omitempty"` + Operator *renderer.Renderable `json:"operator" yaml:"operator"` + Value *renderer.Renderable `json:"value" yaml:"value"` +} + +func (c *Condition) Eval(ctx *Context, input *parser.Node) ([]ConditionResult, error) { + condition, err := c.render(ctx) + if err != nil { + return nil, err + } + + operator := operator.GetOperator(condition.Operator) + if operator == nil { + return nil, RuntimeError{ + msg: fmt.Sprintf("invalid operator %s", condition.Operator), + } + } + + var results []ConditionResult + + if condition.Field == "" { + value, err := condition.Expr.Render(ctx) + if err != nil { + return nil, err + } + + ok, err := operator.Eval(value, condition.Value) + if err != nil { + return nil, err + } + + if !ok { + results = append(results, ConditionResult{ + Expr: value, + Operator: condition.Operator, + Value: condition.Value, + }) + return results, nil + } + return nil, nil + + } else { + + fields, err := input.FindAll(condition.Field) + if err != nil { + return nil, err + } + + for i := range fields { + ctx.Cond.Field = fields[i] + value := fields[i].Value + + if condition.Expr != nil { + value, err = condition.Expr.Render(ctx) + if err != nil { + return nil, err + } + ctx.Cond.Expr = value + } + + ok, err := operator.Eval(value, condition.Value) + if err != nil { + return nil, err + } + if !ok { + result := ConditionResult{ + Field: fields[i], + Operator: condition.Operator, + Value: condition.Value, + } + if condition.Expr != nil { + result.Expr = value + } + results = append(results, result) + } + } + } + + return results, nil +} + +func (c *Condition) render(ctx *Context) (*renderedCondition, error) { + cond := renderedCondition{Expr: c.Expr} + + if c.Field != nil { + field, err := c.Field.Render(ctx) + if err != nil { + return nil, err + } + cond.Field = field.(string) + } + + operator, err := c.Operator.Render(ctx) + if err != nil { + return nil, err + } + + cond.Operator = operator.(string) + + value, err := c.Value.Render(ctx) + if err != nil { + return nil, err + } + + cond.Value = value + + return &cond, nil +} + +func (c *Condition) validate() []parserError { + var result []parserError + + if c.Operator == nil { + result = append(result, parserError{ + msg: "missing condition's operator", + }) + } + + if c.Value == nil { + result = append(result, parserError{ + msg: "missing condition's value", + }) + } + + if c.Field == nil && c.Expr == nil { + result = append(result, parserError{ + msg: "condition must have field or expr defined", + }) + } + + return result +} + +type NestedCondition struct { + Not *NestedCondition `json:"not,omitempty" yaml:"not,omitempty"` + And []NestedCondition `json:"and,omitempty" yaml:"and,omitempty"` + Or []NestedCondition `json:"or,omitempty" yaml:"or,omitempty"` + *Condition `json:",inline,omitempty" yaml:",inline,omitempty"` +} + +func (nc *NestedCondition) Eval(ctx *Context, input *parser.Node) (bool, error) { + if len(nc.And) > 0 { + for _, condition := range nc.And { + ok, err := condition.Eval(ctx, input) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + } + return true, nil + } + if len(nc.Or) > 0 { + for _, condition := range nc.Or { + ok, err := condition.Eval(ctx, input) + if err != nil { + return false, err + } + if ok { + return true, nil + } + } + return false, nil + } + if nc.Not != nil { + ok, err := nc.Not.Eval(ctx, input) + if err != nil { + return false, err + } + return !ok, nil + } + + failures, err := nc.Condition.Eval(ctx, input) + if err != nil { + return false, err + } + + return failures == nil, nil +} + +func (ns *NestedCondition) validate() []parserError { + var result []parserError + + if ns.And == nil && ns.Or == nil && ns.Not == nil && ns.Condition == nil { + result = append(result, parserError{ + msg: "invalid condition", + }) + } + + for i := range ns.And { + if errs := ns.And[i].validate(); errs != nil { + result = append(result, errs...) + } + } + + for i := range ns.Or { + if errs := ns.Or[i].validate(); errs != nil { + result = append(result, errs...) + } + } + + if ns.Not != nil { + if errs := ns.Not.validate(); errs != nil { + result = append(result, errs...) + } + } + + if ns.Condition != nil { + if errs := ns.Condition.validate(); errs != nil { + result = append(result, errs...) + } + } + + return result +} diff --git a/yapl/context.go b/yapl/context.go new file mode 100644 index 0000000..6a2b3ba --- /dev/null +++ b/yapl/context.go @@ -0,0 +1,30 @@ +package yapl + +import ( + "os" + "strings" +) + +type Context struct { + Input interface{} + Params map[string]interface{} + Env map[string]string + Cond ConditionResult +} + +func newContext(input, params map[string]interface{}) *Context { + return &Context{ + Input: input, + Env: environ(), + Params: params, + } +} + +func environ() map[string]string { + vars := make(map[string]string) + for _, v := range os.Environ() { + part := strings.Split(v, "=") + vars[part[0]] = part[1] + } + return vars +} diff --git a/yapl/errors.go b/yapl/errors.go new file mode 100644 index 0000000..3b1f1b6 --- /dev/null +++ b/yapl/errors.go @@ -0,0 +1,34 @@ +package yapl + +import ( + "fmt" + "strings" +) + +type parserError struct { + msg string + location []string +} + +func (pe *parserError) Error() string { + return pe.msg +} + +type ParserError []parserError + +func (perr ParserError) Error() string { + var msgs []string + for i := range perr { + msgs = append(msgs, perr[i].Error()) + } + return fmt.Sprintf("invalid policy\n%s", strings.Join(msgs, "\n")) +} + +type RuntimeError struct { + msg string + location []string +} + +func (rte RuntimeError) Error() string { + return rte.msg +} diff --git a/yapl/policy.go b/yapl/policy.go new file mode 100644 index 0000000..22ab536 --- /dev/null +++ b/yapl/policy.go @@ -0,0 +1,83 @@ +package yapl + +import ( + "github.com/ahsayde/yapl/internal/parser" +) + +type Policy struct { + Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Match *NestedCondition `json:"match,omitempty" yaml:"match,omitempty"` + Exclude *NestedCondition `json:"exclude,omitempty" yaml:"exclude,omitempty"` + Rules []Rule `json:"rules" yaml:"rules"` +} + +func (p *Policy) Eval(input, params map[string]interface{}) (*Result, error) { + ctx := newContext(input, params) + node := parser.Parse(ctx.Input) + + if p.Match != nil { + ok, err := p.Match.Eval(ctx, node) + if err != nil { + return nil, err + } + if !ok { + return &Result{Ignored: true}, nil + } + } + + if p.Exclude != nil { + ok, err := p.Exclude.Eval(ctx, node) + if err != nil { + return nil, err + } + if ok { + return &Result{Ignored: true}, nil + } + } + + result := Result{} + for _, rule := range p.Rules { + results, err := rule.Eval(ctx, node) + if err != nil { + return nil, err + } + result.Results = append(result.Results, results...) + } + + if len(result.Results) > 0 { + result.Failed = true + } else { + result.Passed = true + } + + return &result, nil +} + +func (p *Policy) validate() []parserError { + var result []parserError + + if p.Match != nil { + if errs := p.Match.validate(); errs != nil { + result = append(result, errs...) + } + } + + if p.Exclude != nil { + if errs := p.Exclude.validate(); errs != nil { + result = append(result, errs...) + } + } + + if p.Rules == nil { + result = append(result, parserError{ + msg: "policy must contains at least one rule", + }) + } + + for i := range p.Rules { + if errs := p.Rules[i].validate(); errs != nil { + result = append(result, errs...) + } + } + return result +} diff --git a/yapl/result.go b/yapl/result.go new file mode 100644 index 0000000..3440417 --- /dev/null +++ b/yapl/result.go @@ -0,0 +1,15 @@ +package yapl + +import "encoding/json" + +type Result struct { + Passed bool `json:"passed" yaml:"passed"` + Ignored bool `json:"ignored" yaml:"ignored"` + Failed bool `json:"failed" yaml:"failed"` + Results []interface{} `json:"errors" yaml:"errors"` +} + +func (r *Result) JSON() string { + raw, _ := json.MarshalIndent(r, "", " ") + return string(raw) +} diff --git a/yapl/rule.go b/yapl/rule.go new file mode 100644 index 0000000..f7ae59d --- /dev/null +++ b/yapl/rule.go @@ -0,0 +1,75 @@ +package yapl + +import ( + "github.com/ahsayde/yapl/internal/parser" + "github.com/ahsayde/yapl/internal/renderer" +) + +type Rule struct { + When *NestedCondition `json:"when" yaml:"when"` + Condition *Condition `json:"condition" yaml:"condition"` + Result *renderer.Renderable `json:"result" yaml:"result"` +} + +func (r *Rule) Eval(ctx *Context, input *parser.Node) ([]interface{}, error) { + var results []interface{} + + if r.When != nil { + ok, err := r.When.Eval(ctx, input) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + } + + failures, err := r.Condition.Eval(ctx, input) + if err != nil { + return nil, err + } + + if failures == nil { + return results, nil + } + + for i := range failures { + ctx.Cond = failures[i] + result, err := r.Result.Render(ctx) + if err != nil { + return nil, err + } + + results = append(results, result) + } + + return results, nil +} + +func (ck *Rule) validate() []parserError { + var result []parserError + + if ck.Condition == nil { + result = append(result, parserError{ + msg: "rule must have a condition", + }) + } + + if ck.Result == nil { + result = append(result, parserError{ + msg: "rule must have result", + }) + } + + if ck.When != nil { + if errs := ck.When.validate(); errs != nil { + result = append(result, errs...) + } + } + + if errs := ck.Condition.validate(); errs != nil { + result = append(result, errs...) + } + + return result +} diff --git a/yapl/yapl.go b/yapl/yapl.go new file mode 100644 index 0000000..da06dbc --- /dev/null +++ b/yapl/yapl.go @@ -0,0 +1,21 @@ +package yapl + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +func Parse(data []byte) (*Policy, error) { + var policy Policy + + if err := yaml.Unmarshal(data, &policy); err != nil { + return nil, fmt.Errorf("failed to marshal policy, error: %w", err) + } + + if errs := policy.validate(); errs != nil { + return nil, ParserError(errs) + } + + return &policy, nil +}