From 2d5ff7d15c8c72f81cd4c064c1e55c979c5e4d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Fr=C3=B6hlich?= Date: Sat, 25 Feb 2023 15:04:12 +0100 Subject: [PATCH] feat: use opa for validate and patch --- admissionctrl/mutator/opa_json_patch.go | 77 +++++++++++++++++ admissionctrl/mutator/opa_json_patch_test.go | 74 ++++++++++++++++ admissionctrl/opa/opa.go | 56 ++++++++++++ testdata/opa/mutators/hello_world_meta.rego | 30 +++++++ .../opa/mutators/hello_world_meta_test.rego | 68 +++++++++++++++ testdata/opa/validators/costcenter_meta.rego | 28 ++++++ .../opa/validators/costcenter_meta_test.rego | 28 ++++++ testdata/opa/validators/errors.rego | 9 ++ .../opa/validators/prefixed_policies.rego | 26 ++++++ .../validators/prefixed_policies_test.rego | 85 +++++++++++++++++++ 10 files changed, 481 insertions(+) create mode 100644 admissionctrl/mutator/opa_json_patch.go create mode 100644 admissionctrl/mutator/opa_json_patch_test.go create mode 100644 admissionctrl/opa/opa.go create mode 100644 testdata/opa/mutators/hello_world_meta.rego create mode 100644 testdata/opa/mutators/hello_world_meta_test.rego create mode 100644 testdata/opa/validators/costcenter_meta.rego create mode 100644 testdata/opa/validators/costcenter_meta_test.rego create mode 100644 testdata/opa/validators/errors.rego create mode 100644 testdata/opa/validators/prefixed_policies.rego create mode 100644 testdata/opa/validators/prefixed_policies_test.rego diff --git a/admissionctrl/mutator/opa_json_patch.go b/admissionctrl/mutator/opa_json_patch.go new file mode 100644 index 0000000..b7472e9 --- /dev/null +++ b/admissionctrl/mutator/opa_json_patch.go @@ -0,0 +1,77 @@ +package mutator + +import ( + "context" + "encoding/json" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/nomad/api" + "github.com/mxab/nacp/admissionctrl/opa" +) + +type OpaJsonPatchMutator struct { + ruleSets []*opa.OpaRuleSet + logger hclog.Logger +} + +func (j *OpaJsonPatchMutator) Mutate(job *api.Job) (out *api.Job, warnings []error, err error) { + + ctx := context.TODO() + for _, ruleSet := range j.ruleSets { + result, err := ruleSet.Eval(ctx, job) + if err != nil { + return nil, nil, err + } + patchData, ok := result[0].Bindings["patch"].([]interface{}) + patchJSON, err := json.Marshal(patchData) + if err != nil { + return nil, nil, err + } + + if ok { + patch, err := jsonpatch.DecodePatch(patchJSON) + if err != nil { + return nil, nil, err + } + j.logger.Debug("Got patch fom ruleset %s, patch: %v", ruleSet.Name(), patchJSON) + jobJson, err := json.Marshal(job) + if err != nil { + return nil, nil, err + } + + patched, err := patch.Apply(jobJson) + if err != nil { + return nil, nil, err + } + var patchedJob api.Job + err = json.Unmarshal(patched, &patchedJob) + if err != nil { + return nil, nil, err + } + job = &patchedJob + + } + + } + + return job, nil, nil +} +func (j *OpaJsonPatchMutator) Name() string { + return "jsonpatch" +} + +func NewOpaJsonPatchMutator(rules []opa.OpaQueryAndModule, logger hclog.Logger) (*OpaJsonPatchMutator, error) { + + ctx := context.TODO() + // read the policy file + ruleSets, err := opa.CreateOpaRuleSet(rules, ctx) + if err != nil { + return nil, err + } + return &OpaJsonPatchMutator{ + ruleSets: ruleSets, + logger: logger, + }, nil + +} diff --git a/admissionctrl/mutator/opa_json_patch_test.go b/admissionctrl/mutator/opa_json_patch_test.go new file mode 100644 index 0000000..cc85ef7 --- /dev/null +++ b/admissionctrl/mutator/opa_json_patch_test.go @@ -0,0 +1,74 @@ +package mutator + +import ( + "testing" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/nomad/api" + "github.com/mxab/nacp/admissionctrl/opa" + "github.com/mxab/nacp/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSONPatcher_Mutate(t *testing.T) { + type args struct { + job *api.Job + } + tests := []struct { + name string + j *OpaJsonPatchMutator + args args + wantOut *api.Job + wantWarnings []error + wantErr bool + }{ + { + name: "nothing", + j: newMutator(t, []opa.OpaQueryAndModule{}), + + args: args{ + job: &api.Job{}, + }, + wantOut: &api.Job{}, + wantWarnings: []error{}, + wantErr: false, + }, + { + name: "hello world", + j: newMutator(t, []opa.OpaQueryAndModule{ + { + Filename: testutil.Filepath(t, "opa/mutators/hello_world_meta.rego"), + Query: "patch = data.hello_world_meta.patch", + }, + }), + + args: args{ + job: &api.Job{}, + }, + wantOut: &api.Job{ + Meta: map[string]string{ + "hello": "world", + }, + }, + wantWarnings: []error{}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOut, gotWarnings, err := tt.j.Mutate(tt.args.job) + require.Equal(t, tt.wantErr, err != nil, "JSONPatcher.Mutate() error = %v, wantErr %v", err, tt.wantErr) + assert.Empty(t, gotWarnings) + assert.Equal(t, tt.wantOut, gotOut) + + }) + } +} + +func newMutator(t *testing.T, rules []opa.OpaQueryAndModule) *OpaJsonPatchMutator { + t.Helper() + m, err := NewOpaJsonPatchMutator(rules, hclog.NewNullLogger()) + require.NoError(t, err) + return m +} diff --git a/admissionctrl/opa/opa.go b/admissionctrl/opa/opa.go new file mode 100644 index 0000000..642c9d6 --- /dev/null +++ b/admissionctrl/opa/opa.go @@ -0,0 +1,56 @@ +package opa + +import ( + "context" + "os" + + "github.com/hashicorp/nomad/api" + "github.com/open-policy-agent/opa/rego" +) + +type OpaQueryAndModule struct { + Filename string + Query string +} + +type OpaRuleSet struct { + rule OpaQueryAndModule + prepared *rego.PreparedEvalQuery +} + +func CreateOpaRuleSet(rules []OpaQueryAndModule, ctx context.Context) ([]*OpaRuleSet, error) { + var ruleSets []*OpaRuleSet + + for _, rule := range rules { + + module, err := os.ReadFile(rule.Filename) + if err != nil { + return nil, err + } + + query, err := rego.New( + rego.Query(rule.Query), + rego.Module(rule.Filename, string(module)), + ).PrepareForEval(ctx) + + if err != nil { + return nil, err + } + ruleSets = append(ruleSets, &OpaRuleSet{ + rule: rule, + prepared: &query, + }, + ) + + } + return ruleSets, nil +} + +func (r *OpaRuleSet) Eval(ctx context.Context, job *api.Job) (rego.ResultSet, error) { + + results, err := r.prepared.Eval(ctx, rego.EvalInput(job)) + return results, err +} +func (r *OpaRuleSet) Name() string { + return r.rule.Filename +} diff --git a/testdata/opa/mutators/hello_world_meta.rego b/testdata/opa/mutators/hello_world_meta.rego new file mode 100644 index 0000000..504125d --- /dev/null +++ b/testdata/opa/mutators/hello_world_meta.rego @@ -0,0 +1,30 @@ +package hello_world_meta + + +patch[operation] { + + not input.Meta + operation := { + "op": "add", + "path": "/Meta", + "value": {} + } +} +patch[operation] { + + is_null(input.Meta) + operation := { + "op": "add", + "path": "/Meta", + "value": {} + } +} +patch[operation] { + + not input.Meta.hello + operation := { + "op": "add", + "path": "/Meta/hello", + "value": "world" + } +} \ No newline at end of file diff --git a/testdata/opa/mutators/hello_world_meta_test.rego b/testdata/opa/mutators/hello_world_meta_test.rego new file mode 100644 index 0000000..38ac084 --- /dev/null +++ b/testdata/opa/mutators/hello_world_meta_test.rego @@ -0,0 +1,68 @@ +package hello_world_meta_test + +import data.hello_world_meta.patch + +import future.keywords + +test_hello_world if { + e := patch with input as { + "ID": "my-job", + "Meta": {}, + } + e[{ + "op": "add", + "path": "/Meta/hello", + "value": "world" + }] + +} + +test_hello_world_add_meta if { + e := patch with input as { + "ID": "my-job" + } + count(e) == 2 + trace(sprintf("patch: %v", [e])) + + e == { + { + "op": "add", + "path": "/Meta", + "value": {} + }, + { + "op": "add", + "path": "/Meta/hello", + "value": "world" + } + } +} +test_hello_world_add_meta_if_meta_null if { + e := patch with input as { + "ID": "my-job", + "Meta": null + } + count(e) == 2 + trace(sprintf("patch: %v", [e])) + + e == { + { + "op": "add", + "path": "/Meta", + "value": {} + }, + { + "op": "add", + "path": "/Meta/hello", + "value": "world" + } + } +} +test_hello_world_no_code_if_exists if { + e := patch with input as { + "ID": "my-job", + "Meta": {"hello": "world"} + } + count(e) == 0 + +} diff --git a/testdata/opa/validators/costcenter_meta.rego b/testdata/opa/validators/costcenter_meta.rego new file mode 100644 index 0000000..fa6dc1b --- /dev/null +++ b/testdata/opa/validators/costcenter_meta.rego @@ -0,0 +1,28 @@ + +package costcenter_meta + + +import future.keywords.contains +import future.keywords.if + +# This definition checks if the costcenter label is not provided. Each rule definition +# contributes to the set of error messages. +errors contains msg if { + # The `not` keyword turns an undefined statement into a true statement. If any + # of the keys are missing, this statement will be true. + + + not input.Meta.costcenter + trace("Costcenter code is missing") + + msg := "Every job must have a costcenter metadata label" +} + +# This definition checks if the costcenter label is formatted appropriately. Each rule +# definition contributes to the set of error messages. +errors contains msg if { + value := input.Meta.costcenter + + not startswith(value, "cccode-") + msg := sprintf("Costcenter code must start with `cccode-`; found `%v`", [value]) +} diff --git a/testdata/opa/validators/costcenter_meta_test.rego b/testdata/opa/validators/costcenter_meta_test.rego new file mode 100644 index 0000000..ad5b15a --- /dev/null +++ b/testdata/opa/validators/costcenter_meta_test.rego @@ -0,0 +1,28 @@ +package costcenter_meta_test +import data.costcenter_meta.errors + +import future.keywords + +test_missing_costcenter if { + count(errors) == 1 with input as { + "ID": "my-job", + "Meta": {}, + } + +} + +test_costcenter_prefix_wrong if { + count(errors)==1 with input as { + "ID": "my-job", + "Meta": {"costcenter": "my-costcenter"}, + } + +} + +test_costcenter_correct if { + count(errors) == 0 with input as { + "ID": "my-job", + "Meta": {"costcenter": "cccode-my-costcenter"}, + } + +} diff --git a/testdata/opa/validators/errors.rego b/testdata/opa/validators/errors.rego new file mode 100644 index 0000000..7bcc03a --- /dev/null +++ b/testdata/opa/validators/errors.rego @@ -0,0 +1,9 @@ +package dummy + +errors[errMsg] { + errMsg := "This is a error message" +} + +warnings[warnMsg] { + warnMsg := "This is a warning message" +} \ No newline at end of file diff --git a/testdata/opa/validators/prefixed_policies.rego b/testdata/opa/validators/prefixed_policies.rego new file mode 100644 index 0000000..e2aacd7 --- /dev/null +++ b/testdata/opa/validators/prefixed_policies.rego @@ -0,0 +1,26 @@ +package prefixed_policies + +import future.keywords +import future.keywords.in + +task_group_policies contains name if { + name := input.TaskGroups[_].Vault.Policies[_] +} + +task_policies contains name if { + name := input.TaskGroups[_].Tasks[_].Vault.Policies[_] +} +policy_prefix := sprintf("%s-", [input.ID]) + +errors[msg] { + + some p in task_policies + not startswith(p, policy_prefix) + msg := sprintf("Task policy '%v' must start with '%v'", [p, policy_prefix]) +} +errors[msg] { + + some p in task_group_policies + not startswith(p, policy_prefix) + msg := sprintf("Task group policy '%v' must start with '%v'", [p, policy_prefix]) +} \ No newline at end of file diff --git a/testdata/opa/validators/prefixed_policies_test.rego b/testdata/opa/validators/prefixed_policies_test.rego new file mode 100644 index 0000000..944f9b7 --- /dev/null +++ b/testdata/opa/validators/prefixed_policies_test.rego @@ -0,0 +1,85 @@ +package prefixed_policies_test + +import data.prefixed_policies.errors + +import future.keywords + +test_no_errors if { + count(errors) == 0 with input as { + "ID": "example", + "Name": "example", + "TaskGroups": [{ + "Name": "cache", + "Tasks": [], + }], + "Type": "service", + } + +} + +test_no_errors_for_valid_policy if { + count(errors) == 0 with input as { + "ID": "example", + "Name": "example", + "TaskGroups": [{ + "Name": "cache", + "Tasks": [{"Vault": {"Policies": ["example-redis"]}}], + }], + "Type": "service", + } +} +test_no_errors_for_multi_valid_policy if { + count(errors) == 0 with input as { + "ID": "example", + "Name": "example", + "TaskGroups": [{ + "Name": "cache", + "Tasks": [{"Vault": {"Policies": [ + "example-redis", + "example-mysql", + ]}}], + }], + "Type": "service", + } +} + +test_errors_for_wrong_task_policy if { + count(errors) == 1 with input as { + "ID": "example", + "Name": "example", + "TaskGroups": [{ + "Name": "cache", + "Tasks": [{"Vault": {"Policies": ["some-randome-policy"]}}], + }], + "Type": "service", + } + +} +test_errors_for_multi_wrong_policy { + + count(errors) == 2 with input as { + "ID": "example", + "Name": "example", + "TaskGroups": [{ + "Name": "cache", + "Tasks": [{"Vault": {"Policies": ["some-randome-policy","also-not-valid"]}}], + }], + "Type": "service", + } + + +} + +test_errors_for_wrong_task_group_policy if { + count(errors) == 1 with input as { + "ID": "example", + "Name": "example", + "TaskGroups": [{ + "Vault": {"Policies": ["some-randome-policy"]}, + "Name": "cache", + "Tasks": [], + }], + "Type": "service", + } + +} \ No newline at end of file