From 3617c9b4a6542249bdc3e6dd4749ece0aca2108b Mon Sep 17 00:00:00 2001 From: Ayush Rawat Date: Thu, 26 Feb 2026 17:46:52 +0530 Subject: [PATCH 1/8] Implement Policy Enforcer Plugin - Added a new Policy Enforcer plugin to evaluate incoming messages against OPA policies. - Configurable via YAML with options for policy sources, actions, and query. - Integrated into existing configuration files for BAP and BPP. - Updated related tests and documentation for the new functionality. - Enhanced plugin manager to support Policy Enforcer instantiation. --- cmd/adapter/main_test.go | 9 +- config/local-beckn-one-bap.yaml | 7 + config/local-beckn-one-bpp.yaml | 7 + config/local-simple.yaml | 14 + core/module/handler/config.go | 2 + core/module/handler/stdHandler.go | 6 + core/module/handler/step.go | 8 + core/module/module_test.go | 5 + go.mod | 44 +- go.sum | 137 ++++- install/build-plugins.sh | 1 + install/docker-compose-adapter-beckn-one.yml | 2 + install/docker-compose-adapter.yml | 1 + pkg/plugin/definition/policyEnforcer.go | 17 + .../implementation/policyenforcer/README.md | 68 +++ .../policyenforcer/cmd/plugin.go | 26 + .../implementation/policyenforcer/config.go | 129 +++++ .../implementation/policyenforcer/enforcer.go | 106 ++++ .../policyenforcer/enforcer_test.go | 518 ++++++++++++++++++ .../policyenforcer/evaluator.go | 238 ++++++++ pkg/plugin/manager.go | 17 + policies/compliance.rego | 18 + 22 files changed, 1341 insertions(+), 39 deletions(-) create mode 100644 pkg/plugin/definition/policyEnforcer.go create mode 100644 pkg/plugin/implementation/policyenforcer/README.md create mode 100644 pkg/plugin/implementation/policyenforcer/cmd/plugin.go create mode 100644 pkg/plugin/implementation/policyenforcer/config.go create mode 100644 pkg/plugin/implementation/policyenforcer/enforcer.go create mode 100644 pkg/plugin/implementation/policyenforcer/enforcer_test.go create mode 100644 pkg/plugin/implementation/policyenforcer/evaluator.go create mode 100644 policies/compliance.rego diff --git a/cmd/adapter/main_test.go b/cmd/adapter/main_test.go index 4961a3a..6f7864d 100644 --- a/cmd/adapter/main_test.go +++ b/cmd/adapter/main_test.go @@ -83,6 +83,11 @@ func (m *MockPluginManager) SchemaValidator(ctx context.Context, cfg *plugin.Con return nil, nil } +// PolicyEnforcer returns a mock implementation of the PolicyEnforcer interface. +func (m *MockPluginManager) PolicyEnforcer(ctx context.Context, cfg *plugin.Config) (definition.PolicyEnforcer, error) { + return nil, nil +} + // mockRun is a mock implementation of the `run` function, simulating a successful run. func mockRun(ctx context.Context, configPath string) error { return nil // Simulate a successful run @@ -186,8 +191,8 @@ func TestRunFailure(t *testing.T) { } defer func() { newManagerFunc = originalNewManager }() - originalNewServer := newServerFunc - newServerFunc = func(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) { + originalNewServer := newServerFunc + newServerFunc = func(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) { return tt.mockServer(ctx, mgr, cfg) } defer func() { newServerFunc = originalNewServer }() diff --git a/config/local-beckn-one-bap.yaml b/config/local-beckn-one-bap.yaml index 28ca271..c179ddc 100644 --- a/config/local-beckn-one-bap.yaml +++ b/config/local-beckn-one-bap.yaml @@ -86,6 +86,12 @@ modules: id: router config: routingConfig: ./config/local-beckn-one-routing-BAPReceiver.yaml + policyEnforcer: + id: policyenforcer + config: + policySources: "./policies/compliance.rego" + actions: "confirm" + query: "data.policy.violations" middleware: - id: reqpreprocessor config: @@ -93,6 +99,7 @@ modules: role: bap steps: - validateSign + - enforcePolicy - addRoute - validateSchema diff --git a/config/local-beckn-one-bpp.yaml b/config/local-beckn-one-bpp.yaml index 4d98073..1564a85 100644 --- a/config/local-beckn-one-bpp.yaml +++ b/config/local-beckn-one-bpp.yaml @@ -84,8 +84,15 @@ modules: id: router config: routingConfig: ./config/local-beckn-one-routing-BPPReceiver.yaml + policyEnforcer: + id: policyenforcer + config: + policySources: "./policies/compliance.rego" + actions: "confirm" + query: "data.policy.violations" steps: - validateSign + - enforcePolicy - addRoute - validateSchema diff --git a/config/local-simple.yaml b/config/local-simple.yaml index 327e344..7360f90 100644 --- a/config/local-simple.yaml +++ b/config/local-simple.yaml @@ -66,6 +66,12 @@ modules: id: router config: routingConfig: ./config/local-simple-routing.yaml + policyEnforcer: + id: policyenforcer + config: + policySources: "./policies/compliance.rego" + actions: "confirm" + query: "data.policy.violations" middleware: - id: reqpreprocessor config: @@ -73,6 +79,7 @@ modules: role: bap steps: - validateSign + - enforcePolicy - addRoute - name: bapTxnCaller @@ -162,8 +169,15 @@ modules: id: router config: routingConfig: ./config/local-simple-routing-BPPReceiver.yaml + policyEnforcer: + id: policyenforcer + config: + policySources: "./policies/compliance.rego" + actions: "confirm" + query: "data.policy.violations" steps: - validateSign + - enforcePolicy - addRoute - name: bppTxnCaller diff --git a/core/module/handler/config.go b/core/module/handler/config.go index 96b2263..64f38fe 100644 --- a/core/module/handler/config.go +++ b/core/module/handler/config.go @@ -19,6 +19,7 @@ type PluginManager interface { Publisher(ctx context.Context, cfg *plugin.Config) (definition.Publisher, error) Signer(ctx context.Context, cfg *plugin.Config) (definition.Signer, error) Step(ctx context.Context, cfg *plugin.Config) (definition.Step, error) + PolicyEnforcer(ctx context.Context, cfg *plugin.Config) (definition.PolicyEnforcer, error) Cache(ctx context.Context, cfg *plugin.Config) (definition.Cache, error) Registry(ctx context.Context, cfg *plugin.Config) (definition.RegistryLookup, error) KeyManager(ctx context.Context, cache definition.Cache, rLookup definition.RegistryLookup, cfg *plugin.Config) (definition.KeyManager, error) @@ -37,6 +38,7 @@ const ( // PluginCfg holds the configuration for various plugins. type PluginCfg struct { SchemaValidator *plugin.Config `yaml:"schemaValidator,omitempty"` + PolicyEnforcer *plugin.Config `yaml:"policyEnforcer,omitempty"` SignValidator *plugin.Config `yaml:"signValidator,omitempty"` Publisher *plugin.Config `yaml:"publisher,omitempty"` Signer *plugin.Config `yaml:"signer,omitempty"` diff --git a/core/module/handler/stdHandler.go b/core/module/handler/stdHandler.go index 63444cc..097400e 100644 --- a/core/module/handler/stdHandler.go +++ b/core/module/handler/stdHandler.go @@ -35,6 +35,7 @@ type stdHandler struct { registry definition.RegistryLookup km definition.KeyManager schemaValidator definition.SchemaValidator + policyEnforcer definition.PolicyEnforcer router definition.Router publisher definition.Publisher transportWrapper definition.TransportWrapper @@ -318,6 +319,9 @@ func (h *stdHandler) initPlugins(ctx context.Context, mgr PluginManager, cfg *Pl if h.transportWrapper, err = loadPlugin(ctx, "TransportWrapper", cfg.TransportWrapper, mgr.TransportWrapper); err != nil { return err } + if h.policyEnforcer, err = loadPlugin(ctx, "PolicyEnforcer", cfg.PolicyEnforcer, mgr.PolicyEnforcer); err != nil { + return err + } log.Debugf(ctx, "All required plugins successfully loaded for stdHandler") return nil @@ -350,6 +354,8 @@ func (h *stdHandler) initSteps(ctx context.Context, mgr PluginManager, cfg *Conf s, err = newValidateSchemaStep(h.schemaValidator) case "addRoute": s, err = newAddRouteStep(h.router) + case "enforcePolicy": + s, err = newEnforcePolicyStep(h.policyEnforcer) default: if customStep, exists := steps[step]; exists { s = customStep diff --git a/core/module/handler/step.go b/core/module/handler/step.go index 04d6536..7817709 100644 --- a/core/module/handler/step.go +++ b/core/module/handler/step.go @@ -315,3 +315,11 @@ func extractSchemaVersion(body []byte) string { } return "unknown" } + +// newEnforcePolicyStep creates and returns the enforcePolicy step after validation. +func newEnforcePolicyStep(policyEnforcer definition.PolicyEnforcer) (definition.Step, error) { + if policyEnforcer == nil { + return nil, fmt.Errorf("invalid config: PolicyEnforcer plugin not configured") + } + return policyEnforcer, nil +} diff --git a/core/module/module_test.go b/core/module/module_test.go index f1a0caa..302a2de 100644 --- a/core/module/module_test.go +++ b/core/module/module_test.go @@ -79,6 +79,11 @@ func (m *mockPluginManager) SchemaValidator(ctx context.Context, cfg *plugin.Con return nil, nil } +// PolicyEnforcer returns a mock policy enforcer implementation. +func (m *mockPluginManager) PolicyEnforcer(ctx context.Context, cfg *plugin.Config) (definition.PolicyEnforcer, error) { + return nil, nil +} + // TestRegisterSuccess tests scenarios where the handler registration should succeed. func TestRegisterSuccess(t *testing.T) { mCfgs := []Config{ diff --git a/go.mod b/go.mod index 2030ae7..6fce510 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/beckn-one/beckn-onix -go 1.24.0 +go 1.24.6 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 @@ -22,16 +22,20 @@ require github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03 require golang.org/x/text v0.33.0 // indirect require ( + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -41,33 +45,52 @@ require ( github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/prometheus/client_model v0.6.0 // indirect - github.com/prometheus/common v0.45.0 // indirect - github.com/prometheus/procfs v0.12.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tchap/go-patricia/v2 v2.3.3 // indirect + github.com/valyala/fastjson v1.6.7 // indirect + github.com/vektah/gqlparser/v2 v2.5.31 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/log v0.16.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/ini.v1 v1.67.1 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) require ( @@ -76,16 +99,19 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/vault/api v1.16.0 github.com/jsonata-go/jsonata v0.0.0-20250709164031-599f35f32e5f - github.com/prometheus/client_golang v1.18.0 + github.com/open-policy-agent/opa v1.13.2 + github.com/prometheus/client_golang v1.23.2 github.com/rabbitmq/amqp091-go v1.10.0 github.com/redis/go-redis/extra/redisotel/v9 v9.16.0 github.com/redis/go-redis/v9 v9.16.0 github.com/rs/zerolog v1.34.0 go.opentelemetry.io/contrib/instrumentation/runtime v0.64.0 go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 go.opentelemetry.io/otel/exporters/prometheus v0.46.0 + go.opentelemetry.io/otel/log v0.16.0 go.opentelemetry.io/otel/metric v1.40.0 go.opentelemetry.io/otel/sdk v1.40.0 go.opentelemetry.io/otel/sdk/log v0.16.0 diff --git a/go.sum b/go.sum index 0e2eb1a..fbbed95 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -6,6 +12,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytecodealliance/wasmtime-go/v39 v39.0.1 h1:RibaT47yiyCRxMOj/l2cvL8cWiWBSqDXHyqsa9sGcCE= +github.com/bytecodealliance/wasmtime-go/v39 v39.0.1/go.mod h1:miR4NYIEBXeDNamZIzpskhJ0z/p8al+lwMWylQ/ZJb4= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -17,13 +25,29 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dgraph-io/badger/v4 v4.9.0 h1:tpqWb0NewSrCYqTvywbcXOhQdWcqephkVkbBmaaqHzc= +github.com/dgraph-io/badger/v4 v4.9.0/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= @@ -39,15 +63,19 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -79,10 +107,28 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jsonata-go/jsonata v0.0.0-20250709164031-599f35f32e5f h1:JnGon8QHtmjFPq0NcSu8OTEnQDDEgFME7gtj/NkjCUo= github.com/jsonata-go/jsonata v0.0.0-20250709164031-599f35f32e5f/go.mod h1:rYUEOEiieWXHNCE/eDXV/o5s7jZ2VyUzQKbqVns9pik= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.2 h1:7u4HUaD0NQbf2/n5+fyp+T10hNCsAnwKfqn4A4Baif0= +github.com/lestrrat-go/httprc/v3 v3.0.2/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= +github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -93,8 +139,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= -github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -104,10 +150,14 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/open-policy-agent/opa v1.13.2 h1:c72l7DhxP4g8DEUBOdaU9QBKyA24dZxCcIuZNRZ0yP4= +github.com/open-policy-agent/opa v1.13.2/go.mod h1:M3Asy9yp1YTusUU5VQuENDe92GLmamIuceqjw+C8PHY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -117,16 +167,18 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= -github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= -github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= -github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= -github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= -github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0 h1:zAFQyFxJ3QDwpPUY/CKn22LI5+B8m/lUyffzq2+8ENs= github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0/go.mod h1:ouOc8ujB2wdUG6o0RrqaPl2tI6cenExC0KkJQ+PHXmw= github.com/redis/go-redis/extra/redisotel/v9 v9.16.0 h1:+a9h9qxFXdf3gX0FXnDcz7X44ZBFUPq58Gblq7aMU4s= @@ -143,20 +195,45 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 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/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= +github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k= +github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03 h1:m1h+vudopHsI67FPT9MOncyndWhTcdUoBtI1R1uajGY= github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03/go.mod h1:8sheVFH84v3PCyFY/O02mIgSQY9I6wMYPWsq7mDnEZY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/contrib/instrumentation/runtime v0.64.0 h1:/+/+UjlXjFcdDlXxKL1PouzX8Z2Vl0OxolRKeBEgYDw= go.opentelemetry.io/contrib/instrumentation/runtime v0.64.0/go.mod h1:Ldm/PDuzY2DP7IypudopCR3OCOW42NJlN9+mNEroevo= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= @@ -169,6 +246,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNl go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= go.opentelemetry.io/otel/exporters/prometheus v0.46.0 h1:I8WIFXR351FoLJYuloU4EgXbtNX2URfU/85pUPheIEQ= go.opentelemetry.io/otel/exporters/prometheus v0.46.0/go.mod h1:ztwVUHe5DTR/1v7PeuGRnU5Bbd4QKYwApWmuutKsJSs= go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= @@ -179,6 +258,8 @@ go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ7 go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= +go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4= +go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= @@ -189,47 +270,45 @@ go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI= -golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= +gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= @@ -237,3 +316,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/install/build-plugins.sh b/install/build-plugins.sh index 389f235..0cdfcaa 100755 --- a/install/build-plugins.sh +++ b/install/build-plugins.sh @@ -23,6 +23,7 @@ plugins=( "schemav2validator" "signer" "signvalidator" + "policyenforcer" ) for plugin in "${plugins[@]}"; do diff --git a/install/docker-compose-adapter-beckn-one.yml b/install/docker-compose-adapter-beckn-one.yml index f5785a1..435d4e0 100644 --- a/install/docker-compose-adapter-beckn-one.yml +++ b/install/docker-compose-adapter-beckn-one.yml @@ -36,6 +36,7 @@ services: volumes: - ../config:/app/config - ../schemas:/app/schemas + - ../policies:/app/policies command: ["./server", "--config=/app/config/local-beckn-one-bap.yaml"] onix-bpp: @@ -57,6 +58,7 @@ services: volumes: - ../config:/app/config - ../schemas:/app/schemas + - ../policies:/app/policies command: ["./server", "--config=/app/config/local-beckn-one-bpp.yaml"] sandbox-bap: diff --git a/install/docker-compose-adapter.yml b/install/docker-compose-adapter.yml index b2839dd..90bcd1b 100644 --- a/install/docker-compose-adapter.yml +++ b/install/docker-compose-adapter.yml @@ -36,6 +36,7 @@ services: volumes: - ../config:/app/config - ../schemas:/app/schemas + - ../policies:/app/policies command: ["./server", "--config=/app/config/local-simple.yaml"] # Vault - Key Management Service diff --git a/pkg/plugin/definition/policyEnforcer.go b/pkg/plugin/definition/policyEnforcer.go new file mode 100644 index 0000000..659bda0 --- /dev/null +++ b/pkg/plugin/definition/policyEnforcer.go @@ -0,0 +1,17 @@ +package definition + +import ( + "context" + + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// PolicyEnforcer interface for policy enforcement on incoming messages. +type PolicyEnforcer interface { + Run(ctx *model.StepContext) error +} + +// PolicyEnforcerProvider interface for creating policy enforcers. +type PolicyEnforcerProvider interface { + New(ctx context.Context, config map[string]string) (PolicyEnforcer, func(), error) +} diff --git a/pkg/plugin/implementation/policyenforcer/README.md b/pkg/plugin/implementation/policyenforcer/README.md new file mode 100644 index 0000000..686a36f --- /dev/null +++ b/pkg/plugin/implementation/policyenforcer/README.md @@ -0,0 +1,68 @@ +# Policy Enforcer Plugin + +OPA/Rego-based policy enforcement for beckn-onix adapters. Evaluates incoming beckn messages against configurable policies and NACKs non-compliant requests. + +## Overview + +The `policyenforcer` plugin is a **Step plugin** that: +- Loads `.rego` policy files from local directories, files, URLs, or local paths +- Evaluates incoming messages against compiled OPA policies +- Returns a `BadReqErr` (NACK) when policy violations are detected +- Fails closed on evaluation errors (treats as NACK) +- Is strictly **opt-in** — adapters that don't reference it are unaffected + +## Configuration + +All config keys are passed via `map[string]string` in the adapter YAML config. + +| Key | Required | Default | Description | +|-----|----------|---------|-------------| +| `policyDir` | One of `policyDir`, `policyFile`, or `policyUrls` required | — | Local directory containing `.rego` files | +| `policyFile` | | — | Single local `.rego` file path | +| `policyUrls` | | — | Comma-separated list of URLs or local paths to `.rego` files | +| `query` | No | `data.policy.violations` | Rego query returning violation strings | +| `actions` | No | `confirm` | Comma-separated beckn actions to enforce | +| `enabled` | No | `true` | Enable/disable the plugin | +| `debugLogging` | No | `false` | Enable verbose logging | +| *any other key* | No | — | Forwarded to Rego as `data.config.` | + +### Policy URLs + +`policyUrls` accepts both remote URLs and local file paths, separated by commas: + +```yaml +config: + policyUrls: "https://policies.example.com/compliance.rego,/etc/policies/local.rego,https://policies.example.com/safety.rego" +``` + +### Air-Gapped Deployments + +For environments without internet access, replace any URL with a local file path or volume mount: + +```yaml +config: + policyUrls: "/mounted-policies/compliance.rego,/mounted-policies/safety.rego" +``` + +## Example Config + +```yaml +plugins: + steps: + - id: policyenforcer + config: + policyUrls: "https://policies.example.com/compliance.rego,/local/policies/safety.rego" + actions: "confirm,init" + query: "data.policy.violations" + minDeliveryLeadHours: "4" + debugLogging: "true" +``` + +## Relationship with Schema Validator + +`policyenforcer` and `schemavalidator`/`schemav2validator` are **separate plugins** with different responsibilities: + +- **Schema Validator**: Validates message **structure** against OpenAPI/JSON Schema specs +- **Policy Enforcer**: Evaluates **business rules** via OPA/Rego policies + +They use different plugin interfaces (`SchemaValidator` vs `Step`), different engines, and different error types. Configure them side-by-side in your adapter config as needed. diff --git a/pkg/plugin/implementation/policyenforcer/cmd/plugin.go b/pkg/plugin/implementation/policyenforcer/cmd/plugin.go new file mode 100644 index 0000000..91d25ae --- /dev/null +++ b/pkg/plugin/implementation/policyenforcer/cmd/plugin.go @@ -0,0 +1,26 @@ +// Package main provides the plugin entry point for the Policy Enforcer plugin. +// This file is compiled as a Go plugin (.so) and loaded by beckn-onix at runtime. +package main + +import ( + "context" + + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/policyenforcer" +) + +// provider implements the PolicyEnforcerProvider interface for plugin loading. +type provider struct{} + +// New creates a new PolicyEnforcer instance. +func (p provider) New(ctx context.Context, cfg map[string]string) (definition.PolicyEnforcer, func(), error) { + enforcer, err := policyenforcer.New(cfg) + if err != nil { + return nil, nil, err + } + + return enforcer, enforcer.Close, nil +} + +// Provider is the exported symbol that beckn-onix plugin manager looks up. +var Provider = provider{} diff --git a/pkg/plugin/implementation/policyenforcer/config.go b/pkg/plugin/implementation/policyenforcer/config.go new file mode 100644 index 0000000..7232b3b --- /dev/null +++ b/pkg/plugin/implementation/policyenforcer/config.go @@ -0,0 +1,129 @@ +package policyenforcer + +import ( + "fmt" + "strings" +) + +// Config holds the configuration for the Policy Enforcer plugin. +type Config struct { + // PolicyDir is a local directory containing .rego policy files (all loaded). + // At least one policy source (PolicyDir, PolicyFile, or PolicyUrls) is required. + PolicyDir string + + // PolicyFile is a single local .rego file path. + PolicyFile string + + // PolicyUrls is a list of URLs (or local file paths) pointing to .rego files, + // fetched at startup or read from disk. + // Parsed from the comma-separated "policyUrls" config key. + PolicyUrls []string + + // Query is the Rego query that returns a set of violation strings. + // Default: "data.policy.violations" + Query string + + // Actions is the list of beckn actions to enforce policies on. + // Default: ["confirm"] + Actions []string + + // Enabled controls whether the plugin is active. + Enabled bool + + // DebugLogging enables verbose logging. + DebugLogging bool + + // RuntimeConfig holds arbitrary key-value pairs passed to Rego as data.config. + // Keys like minDeliveryLeadHours are forwarded here. + RuntimeConfig map[string]string +} + +// Known config keys that are handled directly (not forwarded to RuntimeConfig). +var knownKeys = map[string]bool{ + "policyDir": true, + "policyFile": true, + "policyUrls": true, + "query": true, + "actions": true, + "enabled": true, + "debugLogging": true, +} + +// DefaultConfig returns a Config with sensible defaults. +func DefaultConfig() *Config { + return &Config{ + Query: "data.policy.violations", + Actions: []string{"confirm"}, + Enabled: true, + DebugLogging: false, + RuntimeConfig: make(map[string]string), + } +} + +// ParseConfig parses the plugin configuration map into a Config struct. +func ParseConfig(cfg map[string]string) (*Config, error) { + config := DefaultConfig() + + if dir, ok := cfg["policyDir"]; ok && dir != "" { + config.PolicyDir = dir + } + if file, ok := cfg["policyFile"]; ok && file != "" { + config.PolicyFile = file + } + + // Legacy: comma-separated policyUrls + if urls, ok := cfg["policyUrls"]; ok && urls != "" { + for _, u := range strings.Split(urls, ",") { + u = strings.TrimSpace(u) + if u != "" { + config.PolicyUrls = append(config.PolicyUrls, u) + } + } + } + + if config.PolicyDir == "" && config.PolicyFile == "" && len(config.PolicyUrls) == 0 { + return nil, fmt.Errorf("at least one policy source is required (policyDir, policyFile, or policyUrls)") + } + + if query, ok := cfg["query"]; ok && query != "" { + config.Query = query + } + + if actions, ok := cfg["actions"]; ok && actions != "" { + actionList := strings.Split(actions, ",") + config.Actions = make([]string, 0, len(actionList)) + for _, action := range actionList { + action = strings.TrimSpace(action) + if action != "" { + config.Actions = append(config.Actions, action) + } + } + } + + if enabled, ok := cfg["enabled"]; ok { + config.Enabled = enabled == "true" || enabled == "1" + } + + if debug, ok := cfg["debugLogging"]; ok { + config.DebugLogging = debug == "true" || debug == "1" + } + + // Forward unknown keys to RuntimeConfig (e.g., minDeliveryLeadHours) + for k, v := range cfg { + if !knownKeys[k] { + config.RuntimeConfig[k] = v + } + } + + return config, nil +} + +// IsActionEnabled checks if the given action is in the configured actions list. +func (c *Config) IsActionEnabled(action string) bool { + for _, a := range c.Actions { + if a == action { + return true + } + } + return false +} diff --git a/pkg/plugin/implementation/policyenforcer/enforcer.go b/pkg/plugin/implementation/policyenforcer/enforcer.go new file mode 100644 index 0000000..194c72f --- /dev/null +++ b/pkg/plugin/implementation/policyenforcer/enforcer.go @@ -0,0 +1,106 @@ +package policyenforcer + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// PolicyEnforcer is a Step plugin that evaluates beckn messages against +// OPA policies and NACKs non-compliant messages. +type PolicyEnforcer struct { + config *Config + evaluator *Evaluator +} + +// New creates a new PolicyEnforcer instance. +func New(cfg map[string]string) (*PolicyEnforcer, error) { + config, err := ParseConfig(cfg) + if err != nil { + return nil, fmt.Errorf("policyenforcer: config error: %w", err) + } + + evaluator, err := NewEvaluator(config.PolicyDir, config.PolicyFile, config.PolicyUrls, config.Query, config.RuntimeConfig) + if err != nil { + return nil, fmt.Errorf("policyenforcer: failed to initialize OPA evaluator: %w", err) + } + + log.Infof(context.TODO(), "PolicyEnforcer initialized (actions=%v, query=%s, policies=%v, debugLogging=%v)", + config.Actions, config.Query, evaluator.ModuleNames(), config.DebugLogging) + + return &PolicyEnforcer{ + config: config, + evaluator: evaluator, + }, nil +} + +// Run implements the Step interface. It evaluates the message body against +// loaded OPA policies. Returns a BadReqErr (causing NACK) if violations are found. +// Returns an error on evaluation failure (fail closed). +func (e *PolicyEnforcer) Run(ctx *model.StepContext) error { + if !e.config.Enabled { + log.Debug(ctx, "PolicyEnforcer: plugin disabled, skipping") + return nil + } + + // Extract action from the message + action := extractAction(ctx.Request.URL.Path, ctx.Body) + + if !e.config.IsActionEnabled(action) { + if e.config.DebugLogging { + log.Debugf(ctx, "PolicyEnforcer: action %q not in configured actions %v, skipping", action, e.config.Actions) + } + return nil + } + + if e.config.DebugLogging { + log.Debugf(ctx, "PolicyEnforcer: evaluating policies for action %q (modules=%v)", action, e.evaluator.ModuleNames()) + } + + violations, err := e.evaluator.Evaluate(ctx, ctx.Body) + if err != nil { + // Fail closed: evaluation error → NACK + log.Errorf(ctx, err, "PolicyEnforcer: policy evaluation failed: %v", err) + return model.NewBadReqErr(fmt.Errorf("policy evaluation error: %w", err)) + } + + if len(violations) == 0 { + if e.config.DebugLogging { + log.Debugf(ctx, "PolicyEnforcer: message compliant for action %q", action) + } + return nil + } + + // Non-compliant: NACK with all violation messages + msg := fmt.Sprintf("policy violation(s): %s", strings.Join(violations, "; ")) + log.Warnf(ctx, "PolicyEnforcer: %s", msg) + return model.NewBadReqErr(fmt.Errorf("%s", msg)) +} + +// Close is a no-op for the policy enforcer (no resources to release). +func (e *PolicyEnforcer) Close() {} + +// extractAction gets the beckn action from the URL path or message body. +func extractAction(urlPath string, body []byte) string { + // Try URL path first: /bap/receiver/{action} or /bpp/caller/{action} + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + if len(parts) >= 3 { + return parts[len(parts)-1] + } + + // Fallback: extract from body context.action + var payload struct { + Context struct { + Action string `json:"action"` + } `json:"context"` + } + if err := json.Unmarshal(body, &payload); err == nil && payload.Context.Action != "" { + return payload.Context.Action + } + + return "" +} diff --git a/pkg/plugin/implementation/policyenforcer/enforcer_test.go b/pkg/plugin/implementation/policyenforcer/enforcer_test.go new file mode 100644 index 0000000..8f6f811 --- /dev/null +++ b/pkg/plugin/implementation/policyenforcer/enforcer_test.go @@ -0,0 +1,518 @@ +package policyenforcer + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// Helper: create a StepContext with the given action path and JSON body. +func makeStepCtx(action string, body string) *model.StepContext { + req, _ := http.NewRequest("POST", "/bpp/caller/"+action, nil) + return &model.StepContext{ + Context: context.Background(), + Request: req, + Body: []byte(body), + } +} + +// Helper: write a .rego file to a temp dir and return the dir path. +func writePolicyDir(t *testing.T, filename, content string) string { + t.Helper() + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644) + if err != nil { + t.Fatalf("failed to write policy file: %v", err) + } + return dir +} + +// --- Config Tests --- + +func TestParseConfig_RequiresPolicySource(t *testing.T) { + _, err := ParseConfig(map[string]string{}) + if err == nil { + t.Fatal("expected error when no policyDir, policyFile, or policyUrls given") + } +} + +func TestParseConfig_Defaults(t *testing.T) { + cfg, err := ParseConfig(map[string]string{"policyDir": "/tmp"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Query != "data.policy.violations" { + t.Errorf("expected default query, got %q", cfg.Query) + } + if len(cfg.Actions) != 1 || cfg.Actions[0] != "confirm" { + t.Errorf("expected default actions [confirm], got %v", cfg.Actions) + } + if !cfg.Enabled { + t.Error("expected enabled=true by default") + } +} + +func TestParseConfig_RuntimeConfigForwarding(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "policyDir": "/tmp", + "minDeliveryLeadHours": "6", + "customParam": "value", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.RuntimeConfig["minDeliveryLeadHours"] != "6" { + t.Errorf("expected minDeliveryLeadHours=6, got %q", cfg.RuntimeConfig["minDeliveryLeadHours"]) + } + if cfg.RuntimeConfig["customParam"] != "value" { + t.Errorf("expected customParam=value, got %q", cfg.RuntimeConfig["customParam"]) + } +} + +func TestParseConfig_CustomActions(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "policyDir": "/tmp", + "actions": "confirm, select, init", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.Actions) != 3 { + t.Fatalf("expected 3 actions, got %d: %v", len(cfg.Actions), cfg.Actions) + } + expected := []string{"confirm", "select", "init"} + for i, want := range expected { + if cfg.Actions[i] != want { + t.Errorf("action[%d] = %q, want %q", i, cfg.Actions[i], want) + } + } +} + +func TestParseConfig_PolicyUrls(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "policyUrls": "https://example.com/a.rego, https://example.com/b.rego", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.PolicyUrls) != 2 { + t.Fatalf("expected 2 URLs, got %d: %v", len(cfg.PolicyUrls), cfg.PolicyUrls) + } + if cfg.PolicyUrls[0] != "https://example.com/a.rego" { + t.Errorf("url[0] = %q", cfg.PolicyUrls[0]) + } +} + +// Note: policySources support was removed; we intentionally only support +// comma-separated policyUrls and local paths via policyUrls entries. + +// --- Evaluator Tests (with inline policies) --- + +func TestEvaluator_NoViolations(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains msg if { + input.value < 0 + msg := "value is negative" +} +` + dir := writePolicyDir(t, "test.rego", policy) + eval, err := NewEvaluator(dir, "", nil, "data.policy.violations", nil) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{"value": 10}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %d: %v", len(violations), violations) + } +} + +func TestEvaluator_WithViolation(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains msg if { + input.value < 0 + msg := "value is negative" +} +` + dir := writePolicyDir(t, "test.rego", policy) + eval, err := NewEvaluator(dir, "", nil, "data.policy.violations", nil) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{"value": -5}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations) + } + if violations[0] != "value is negative" { + t.Errorf("unexpected violation: %q", violations[0]) + } +} + +func TestEvaluator_RuntimeConfig(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains msg if { + input.value > to_number(data.config.maxValue) + msg := "value exceeds maximum" +} +` + dir := writePolicyDir(t, "test.rego", policy) + eval, err := NewEvaluator(dir, "", nil, "data.policy.violations", map[string]string{"maxValue": "100"}) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + // Under limit + violations, err := eval.Evaluate(context.Background(), []byte(`{"value": 50}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations for value=50, got %v", violations) + } + + // Over limit + violations, err = eval.Evaluate(context.Background(), []byte(`{"value": 150}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Errorf("expected 1 violation for value=150, got %v", violations) + } +} + +func TestEvaluator_SkipsTestFiles(t *testing.T) { + dir := t.TempDir() + + policy := ` +package policy +import rego.v1 +violations contains "always" if { true } +` + os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(policy), 0644) + + // Test file would cause compilation issues if loaded (different package) + testFile := ` +package policy_test +import rego.v1 +import data.policy +test_something if { count(policy.violations) > 0 } +` + os.WriteFile(filepath.Join(dir, "policy_test.rego"), []byte(testFile), 0644) + + eval, err := NewEvaluator(dir, "", nil, "data.policy.violations", nil) + if err != nil { + t.Fatalf("NewEvaluator should skip _test.rego files, but failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Errorf("expected 1 violation, got %d", len(violations)) + } +} + +func TestEvaluator_InvalidJSON(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations := set() +` + dir := writePolicyDir(t, "test.rego", policy) + eval, err := NewEvaluator(dir, "", nil, "data.policy.violations", nil) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + _, err = eval.Evaluate(context.Background(), []byte(`not json`)) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +// --- Evaluator URL Fetch Tests --- + +func TestEvaluator_FetchFromURL(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains msg if { + input.value < 0 + msg := "value is negative" +} +` + // Serve the policy via a local HTTP server + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(policy)) + })) + defer srv.Close() + + eval, err := NewEvaluator("", "", []string{srv.URL + "/test_policy.rego"}, "data.policy.violations", nil) + if err != nil { + t.Fatalf("NewEvaluator with URL failed: %v", err) + } + + // Compliant + violations, err := eval.Evaluate(context.Background(), []byte(`{"value": 10}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %v", violations) + } + + // Non-compliant + violations, err = eval.Evaluate(context.Background(), []byte(`{"value": -1}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Errorf("expected 1 violation, got %v", violations) + } +} + +func TestEvaluator_FetchURL_NotFound(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + + _, err := NewEvaluator("", "", []string{srv.URL + "/missing.rego"}, "data.policy.violations", nil) + if err == nil { + t.Fatal("expected error for 404 URL") + } +} + +func TestEvaluator_FetchURL_InvalidScheme(t *testing.T) { + _, err := NewEvaluator("", "", []string{"ftp://example.com/policy.rego"}, "data.policy.violations", nil) + if err == nil { + t.Fatal("expected error for ftp:// scheme") + } +} + +func TestEvaluator_MixedLocalAndURL(t *testing.T) { + // Local policy + localPolicy := ` +package policy +import rego.v1 +violations contains "local_violation" if { input.local_bad } +` + dir := writePolicyDir(t, "local.rego", localPolicy) + + // Remote policy (different rule, same package) + remotePolicy := ` +package policy +import rego.v1 +violations contains "remote_violation" if { input.remote_bad } +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(remotePolicy)) + })) + defer srv.Close() + + eval, err := NewEvaluator(dir, "", []string{srv.URL + "/remote.rego"}, "data.policy.violations", nil) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + // Trigger both violations + violations, err := eval.Evaluate(context.Background(), []byte(`{"local_bad": true, "remote_bad": true}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 2 { + t.Errorf("expected 2 violations (local+remote), got %d: %v", len(violations), violations) + } +} + +// --- Evaluator with local file path in policySources --- + +func TestEvaluator_LocalFilePath(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "from_file" if { input.bad } +` + dir := t.TempDir() + policyPath := filepath.Join(dir, "local_policy.rego") + os.WriteFile(policyPath, []byte(policy), 0644) + + eval, err := NewEvaluator("", "", []string{policyPath}, "data.policy.violations", nil) + if err != nil { + t.Fatalf("NewEvaluator with local path failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{"bad": true}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 || violations[0] != "from_file" { + t.Errorf("expected [from_file], got %v", violations) + } +} + +// --- Enforcer Integration Tests --- + +func TestEnforcer_Compliant(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "blocked" if { input.context.action == "confirm"; input.block } +` + dir := writePolicyDir(t, "test.rego", policy) + + enforcer, err := New(map[string]string{ + "policyDir": dir, + "actions": "confirm", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}, "block": false}`) + err = enforcer.Run(ctx) + if err != nil { + t.Errorf("expected nil error for compliant message, got: %v", err) + } +} + +func TestEnforcer_NonCompliant(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "blocked" if { input.context.action == "confirm" } +` + dir := writePolicyDir(t, "test.rego", policy) + + enforcer, err := New(map[string]string{ + "policyDir": dir, + "actions": "confirm", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}}`) + err = enforcer.Run(ctx) + if err == nil { + t.Fatal("expected error for non-compliant message, got nil") + } + + // Should be a BadReqErr + if _, ok := err.(*model.BadReqErr); !ok { + t.Errorf("expected *model.BadReqErr, got %T: %v", err, err) + } +} + +func TestEnforcer_SkipsNonMatchingAction(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "blocked" if { true } +` + dir := writePolicyDir(t, "test.rego", policy) + + enforcer, err := New(map[string]string{ + "policyDir": dir, + "actions": "confirm", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + // Non-compliant body, but action is "search" — not in configured actions + ctx := makeStepCtx("search", `{"context": {"action": "search"}}`) + err = enforcer.Run(ctx) + if err != nil { + t.Errorf("expected nil for non-matching action, got: %v", err) + } +} + +func TestEnforcer_DisabledPlugin(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "blocked" if { true } +` + dir := writePolicyDir(t, "test.rego", policy) + + enforcer, err := New(map[string]string{ + "policyDir": dir, + "enabled": "false", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}}`) + err = enforcer.Run(ctx) + if err != nil { + t.Errorf("expected nil for disabled plugin, got: %v", err) + } +} + +// --- Enforcer with URL-sourced policy --- + +func TestEnforcer_PolicyFromURL(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "blocked" if { input.context.action == "confirm" } +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(policy)) + })) + defer srv.Close() + + enforcer, err := New(map[string]string{ + "policyUrls": srv.URL + "/block_confirm.rego", + "actions": "confirm", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}}`) + err = enforcer.Run(ctx) + if err == nil { + t.Fatal("expected error from URL-sourced policy, got nil") + } + if _, ok := err.(*model.BadReqErr); !ok { + t.Errorf("expected *model.BadReqErr, got %T", err) + } +} + +// --- extractAction Tests --- + +func TestExtractAction_FromURL(t *testing.T) { + action := extractAction("/bpp/caller/confirm", nil) + if action != "confirm" { + t.Errorf("expected 'confirm', got %q", action) + } +} + +func TestExtractAction_FromBody(t *testing.T) { + body := []byte(`{"context": {"action": "select"}}`) + action := extractAction("/x", body) + if action != "select" { + t.Errorf("expected 'select', got %q", action) + } +} diff --git a/pkg/plugin/implementation/policyenforcer/evaluator.go b/pkg/plugin/implementation/policyenforcer/evaluator.go new file mode 100644 index 0000000..8e93b71 --- /dev/null +++ b/pkg/plugin/implementation/policyenforcer/evaluator.go @@ -0,0 +1,238 @@ +package policyenforcer + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/storage/inmem" +) + +// Evaluator wraps the OPA engine: loads and compiles .rego files at startup, +// then evaluates messages against the compiled policy set. +type Evaluator struct { + preparedQuery rego.PreparedEvalQuery + query string + runtimeConfig map[string]string + moduleNames []string // names of loaded .rego modules +} + +// ModuleNames returns the names of the loaded .rego policy modules. +func (e *Evaluator) ModuleNames() []string { + return e.moduleNames +} + +// policyFetchTimeout is the HTTP timeout for fetching remote .rego files. +const policyFetchTimeout = 30 * time.Second + +// maxPolicySize is the maximum size of a single .rego file fetched from a URL (1 MB). +const maxPolicySize = 1 << 20 + +// NewEvaluator creates an Evaluator by loading .rego files from local paths +// and/or URLs, then compiling them. runtimeConfig is passed to Rego as data.config. +func NewEvaluator(policyDir, policyFile string, policyUrls []string, query string, runtimeConfig map[string]string) (*Evaluator, error) { + modules := make(map[string]string) + + // Load from local directory + if policyDir != "" { + entries, err := os.ReadDir(policyDir) + if err != nil { + return nil, fmt.Errorf("failed to read policy directory %s: %w", policyDir, err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + if !strings.HasSuffix(entry.Name(), ".rego") { + continue + } + // Skip test files — they shouldn't be compiled into the runtime evaluator + if strings.HasSuffix(entry.Name(), "_test.rego") { + continue + } + fpath := filepath.Join(policyDir, entry.Name()) + data, err := os.ReadFile(fpath) + if err != nil { + return nil, fmt.Errorf("failed to read policy file %s: %w", fpath, err) + } + modules[entry.Name()] = string(data) + } + } + + // Load single local file + if policyFile != "" { + data, err := os.ReadFile(policyFile) + if err != nil { + return nil, fmt.Errorf("failed to read policy file %s: %w", policyFile, err) + } + modules[filepath.Base(policyFile)] = string(data) + } + + // Load from URLs and local file paths (policyUrls) + for _, rawSource := range policyUrls { + if isURL(rawSource) { + name, content, err := fetchPolicy(rawSource) + if err != nil { + return nil, fmt.Errorf("failed to fetch policy from %s: %w", rawSource, err) + } + modules[name] = content + } else { + // Treat as local file path + data, err := os.ReadFile(rawSource) + if err != nil { + return nil, fmt.Errorf("failed to read local policy source %s: %w", rawSource, err) + } + modules[filepath.Base(rawSource)] = string(data) + } + } + + if len(modules) == 0 { + return nil, fmt.Errorf("no .rego policy files found from any configured source") + } + + // Compile modules to catch syntax errors early + compiler, err := ast.CompileModulesWithOpt(modules, ast.CompileOpts{ParserOptions: ast.ParserOptions{RegoVersion: ast.RegoV1}}) + if err != nil { + return nil, fmt.Errorf("failed to compile rego modules: %w", err) + } + + // Build data.config from runtime config + store := map[string]interface{}{ + "config": toInterfaceMap(runtimeConfig), + } + + pq, err := rego.New( + rego.Query(query), + rego.Compiler(compiler), + rego.Store(inmem.NewFromObject(store)), + ).PrepareForEval(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to prepare rego query %q: %w", query, err) + } + + names := make([]string, 0, len(modules)) + for name := range modules { + names = append(names, name) + } + + return &Evaluator{ + preparedQuery: pq, + query: query, + runtimeConfig: runtimeConfig, + moduleNames: names, + }, nil +} + +// isURL checks if a source string looks like a remote URL. +func isURL(source string) bool { + return strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") +} + +// fetchPolicy downloads a .rego file from a URL and returns (filename, content, error). +func fetchPolicy(rawURL string) (string, string, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return "", "", fmt.Errorf("invalid URL: %w", err) + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", "", fmt.Errorf("unsupported URL scheme %q (only http and https are supported)", parsed.Scheme) + } + + client := &http.Client{Timeout: policyFetchTimeout} + resp, err := client.Get(rawURL) + if err != nil { + return "", "", fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, rawURL) + } + + // Read with size limit + limited := io.LimitReader(resp.Body, maxPolicySize+1) + body, err := io.ReadAll(limited) + if err != nil { + return "", "", fmt.Errorf("failed to read response body: %w", err) + } + if len(body) > maxPolicySize { + return "", "", fmt.Errorf("policy file exceeds maximum size of %d bytes", maxPolicySize) + } + + // Derive filename from URL path + name := path.Base(parsed.Path) + if name == "" || name == "." || name == "/" { + name = "policy.rego" + } + if !strings.HasSuffix(name, ".rego") { + name += ".rego" + } + + return name, string(body), nil +} + +// Evaluate runs the compiled policy against a JSON message body. +// Returns a list of violation strings (empty = compliant). +func (e *Evaluator) Evaluate(ctx context.Context, body []byte) ([]string, error) { + var input interface{} + if err := json.Unmarshal(body, &input); err != nil { + return nil, fmt.Errorf("failed to parse message body as JSON: %w", err) + } + + rs, err := e.preparedQuery.Eval(ctx, rego.EvalInput(input)) + if err != nil { + return nil, fmt.Errorf("rego evaluation failed: %w", err) + } + + return extractViolations(rs) +} + +// extractViolations pulls string violations from the OPA result set. +// The query is expected to return a set of strings. +func extractViolations(rs rego.ResultSet) ([]string, error) { + if len(rs) == 0 { + return nil, nil + } + + var violations []string + for _, result := range rs { + for _, expr := range result.Expressions { + switch v := expr.Value.(type) { + case []interface{}: + // Result is a list (from set) + for _, item := range v { + if s, ok := item.(string); ok { + violations = append(violations, s) + } + } + case map[string]interface{}: + // OPA sometimes returns sets as maps with string keys + for key := range v { + violations = append(violations, key) + } + } + } + } + + return violations, nil +} + +// toInterfaceMap converts map[string]string to map[string]interface{} for OPA store. +func toInterfaceMap(m map[string]string) map[string]interface{} { + result := make(map[string]interface{}, len(m)) + for k, v := range m { + result[k] = v + } + return result +} diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go index ef00dd8..d201945 100644 --- a/pkg/plugin/manager.go +++ b/pkg/plugin/manager.go @@ -257,6 +257,23 @@ func (m *Manager) Step(ctx context.Context, cfg *Config) (definition.Step, error return step, error } +// PolicyEnforcer returns a PolicyEnforcer instance based on the provided configuration. +// It registers a cleanup function for resource management. +func (m *Manager) PolicyEnforcer(ctx context.Context, cfg *Config) (definition.PolicyEnforcer, error) { + pp, err := provider[definition.PolicyEnforcerProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + enforcer, closer, err := pp.New(ctx, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, closer) + } + return enforcer, nil +} + // Cache returns a Cache instance based on the provided configuration. // It registers a cleanup function for resource management. func (m *Manager) Cache(ctx context.Context, cfg *Config) (definition.Cache, error) { diff --git a/policies/compliance.rego b/policies/compliance.rego new file mode 100644 index 0000000..5ab3793 --- /dev/null +++ b/policies/compliance.rego @@ -0,0 +1,18 @@ +package policy + +import rego.v1 + +# Example policy: validate confirm action messages. +# This is a sample policy file. Replace with your actual business rules. +# +# The policy evaluates incoming beckn messages and produces a set of +# violation strings. If any violations exist, the adapter will NACK +# the request. +# +# Available inputs: +# - input: the full JSON message body +# - data.config: runtime config from the adapter config (e.g., minDeliveryLeadHours) + +# violations is the set of policy violation messages. +# An empty set means the message is compliant. +violations := set() From e22b79e13725a84feaada59941e9926c7f239780 Mon Sep 17 00:00:00 2001 From: Ayush Rawat Date: Tue, 3 Mar 2026 10:04:22 +0530 Subject: [PATCH 2/8] Refactor Policy Enforcer Configuration - Updated the Policy Enforcer configuration across multiple YAML files to use a unified `policyDir` instead of individual policy sources. - Changed the step name from `enforcePolicy` to `policyEnforcer` for consistency. - Enhanced the documentation to clarify the use of `policyUrls`, `policyDir`, and `policyFile` for policy sources. - Adjusted related code and tests to accommodate the new configuration structure. - Added documentation for using YAML folded scalar (>-) to keep long comma-separated policyUrls values readable across multiple lines. --- config/local-beckn-one-bap.yaml | 6 +- config/local-beckn-one-bpp.yaml | 6 +- config/local-simple.yaml | 12 ++-- config/onix/adapter.yaml | 20 ++++++ core/module/handler/stdHandler.go | 2 +- .../implementation/policyenforcer/README.md | 67 ++++++++++++++----- .../implementation/policyenforcer/config.go | 21 ++++-- .../policyenforcer/enforcer_test.go | 11 ++- .../policyenforcer/evaluator.go | 19 +++++- 9 files changed, 123 insertions(+), 41 deletions(-) diff --git a/config/local-beckn-one-bap.yaml b/config/local-beckn-one-bap.yaml index c179ddc..84f6090 100644 --- a/config/local-beckn-one-bap.yaml +++ b/config/local-beckn-one-bap.yaml @@ -89,9 +89,7 @@ modules: policyEnforcer: id: policyenforcer config: - policySources: "./policies/compliance.rego" - actions: "confirm" - query: "data.policy.violations" + policyDir: "./policies" middleware: - id: reqpreprocessor config: @@ -99,7 +97,7 @@ modules: role: bap steps: - validateSign - - enforcePolicy + - policyEnforcer - addRoute - validateSchema diff --git a/config/local-beckn-one-bpp.yaml b/config/local-beckn-one-bpp.yaml index 1564a85..812c588 100644 --- a/config/local-beckn-one-bpp.yaml +++ b/config/local-beckn-one-bpp.yaml @@ -87,12 +87,10 @@ modules: policyEnforcer: id: policyenforcer config: - policySources: "./policies/compliance.rego" - actions: "confirm" - query: "data.policy.violations" + policyDir: "./policies" steps: - validateSign - - enforcePolicy + - policyEnforcer - addRoute - validateSchema diff --git a/config/local-simple.yaml b/config/local-simple.yaml index 7360f90..270d046 100644 --- a/config/local-simple.yaml +++ b/config/local-simple.yaml @@ -69,9 +69,7 @@ modules: policyEnforcer: id: policyenforcer config: - policySources: "./policies/compliance.rego" - actions: "confirm" - query: "data.policy.violations" + policyDir: "./policies" middleware: - id: reqpreprocessor config: @@ -79,7 +77,7 @@ modules: role: bap steps: - validateSign - - enforcePolicy + - policyEnforcer - addRoute - name: bapTxnCaller @@ -172,12 +170,10 @@ modules: policyEnforcer: id: policyenforcer config: - policySources: "./policies/compliance.rego" - actions: "confirm" - query: "data.policy.violations" + policyDir: "./policies" steps: - validateSign - - enforcePolicy + - policyEnforcer - addRoute - name: bppTxnCaller diff --git a/config/onix/adapter.yaml b/config/onix/adapter.yaml index 200388c..77485af 100644 --- a/config/onix/adapter.yaml +++ b/config/onix/adapter.yaml @@ -48,6 +48,10 @@ modules: id: schemavalidator config: schemaDir: /mnt/gcs/configs/schemas + policyEnforcer: + id: policyenforcer + config: + policyDir: "./policies" signValidator: id: signvalidator publisher: @@ -66,6 +70,7 @@ modules: role: bap steps: - validateSign + - policyEnforcer - addRoute - validateSchema - name: bapTxnCaller @@ -98,6 +103,10 @@ modules: id: schemavalidator config: schemaDir: /mnt/gcs/configs/schemas + policyEnforcer: + id: policyenforcer + config: + policyDir: "./policies" signer: id: signer publisher: @@ -116,6 +125,7 @@ modules: role: bap steps: - validateSchema + - policyEnforcer - addRoute - sign - name: bppTxnReciever @@ -149,6 +159,10 @@ modules: id: schemavalidator config: schemaDir: /mnt/gcs/configs/schemas + policyEnforcer: + id: policyenforcer + config: + policyDir: "./policies" signValidator: id: signvalidator publisher: @@ -167,6 +181,7 @@ modules: role: bpp steps: - validateSign + - policyEnforcer - addRoute - validateSchema - name: bppTxnCaller @@ -199,6 +214,10 @@ modules: id: schemavalidator config: schemaDir: /mnt/gcs/configs/schemas + policyEnforcer: + id: policyenforcer + config: + policyDir: "./policies" signer: id: signer publisher: @@ -217,5 +236,6 @@ modules: role: bpp steps: - validateSchema + - policyEnforcer - addRoute - sign \ No newline at end of file diff --git a/core/module/handler/stdHandler.go b/core/module/handler/stdHandler.go index 097400e..6b3e1c3 100644 --- a/core/module/handler/stdHandler.go +++ b/core/module/handler/stdHandler.go @@ -354,7 +354,7 @@ func (h *stdHandler) initSteps(ctx context.Context, mgr PluginManager, cfg *Conf s, err = newValidateSchemaStep(h.schemaValidator) case "addRoute": s, err = newAddRouteStep(h.router) - case "enforcePolicy": + case "policyEnforcer": s, err = newEnforcePolicyStep(h.policyEnforcer) default: if customStep, exists := steps[step]; exists { diff --git a/pkg/plugin/implementation/policyenforcer/README.md b/pkg/plugin/implementation/policyenforcer/README.md index 686a36f..df77fab 100644 --- a/pkg/plugin/implementation/policyenforcer/README.md +++ b/pkg/plugin/implementation/policyenforcer/README.md @@ -17,27 +17,63 @@ All config keys are passed via `map[string]string` in the adapter YAML config. | Key | Required | Default | Description | |-----|----------|---------|-------------| -| `policyDir` | One of `policyDir`, `policyFile`, or `policyUrls` required | — | Local directory containing `.rego` files | +| `policyUrls` | At least one of `policyUrls`, `policyDir`, or `policyFile` required | — | Comma-separated list of URLs, local file paths, or directory paths to `.rego` files | +| `policyDir` | | `./policies` | Local directory containing `.rego` files | | `policyFile` | | — | Single local `.rego` file path | -| `policyUrls` | | — | Comma-separated list of URLs or local paths to `.rego` files | | `query` | No | `data.policy.violations` | Rego query returning violation strings | -| `actions` | No | `confirm` | Comma-separated beckn actions to enforce | +| `actions` | No | *(empty — all actions)* | Comma-separated beckn actions to enforce. When omitted, all actions are evaluated and the Rego policy itself decides which to gate. | | `enabled` | No | `true` | Enable/disable the plugin | | `debugLogging` | No | `false` | Enable verbose logging | | *any other key* | No | — | Forwarded to Rego as `data.config.` | -### Policy URLs +### Policy Sources -`policyUrls` accepts both remote URLs and local file paths, separated by commas: +`policyUrls` is the primary configuration key. It accepts a comma-separated list of: +- **Remote URLs**: `https://policies.example.com/compliance.rego` +- **Local file paths**: `/etc/policies/local.rego` +- **Directory paths**: `/etc/policies/` (all `.rego` files loaded, `_test.rego` excluded) ```yaml config: - policyUrls: "https://policies.example.com/compliance.rego,/etc/policies/local.rego,https://policies.example.com/safety.rego" + policyUrls: "https://policies.example.com/compliance.rego,/etc/policies/,/local/safety.rego" +``` + +When specifying many URLs, use the YAML folded scalar (`>-`) to keep the config readable: + +```yaml +config: + policyUrls: >- + https://policies.example.com/compliance.rego, + https://policies.example.com/safety.rego, + https://policies.example.com/rate-limit.rego, + /local/policies/, + https://policies.example.com/auth.rego +``` + +The `>-` folds newlines into spaces, so the value is parsed as a single comma-separated string. + +### Minimal Config + +By default, the plugin loads `.rego` files from `./policies` and uses the query `data.policy.violations`. A zero-config setup works if your policies are in the default directory: + +```yaml +policyEnforcer: + id: policyenforcer + config: {} +``` + +Or specify a custom policy location: + +```yaml +policyEnforcer: + id: policyenforcer + config: + policyUrls: "./policies/compliance.rego" ``` ### Air-Gapped Deployments -For environments without internet access, replace any URL with a local file path or volume mount: +For environments without internet access, use local file paths or volume mounts: ```yaml config: @@ -48,14 +84,15 @@ config: ```yaml plugins: - steps: - - id: policyenforcer - config: - policyUrls: "https://policies.example.com/compliance.rego,/local/policies/safety.rego" - actions: "confirm,init" - query: "data.policy.violations" - minDeliveryLeadHours: "4" - debugLogging: "true" + policyEnforcer: + id: policyenforcer + config: + policyUrls: "https://policies.example.com/compliance.rego,/local/policies/" + minDeliveryLeadHours: "4" + debugLogging: "true" +steps: + - policyEnforcer + - addRoute ``` ## Relationship with Schema Validator diff --git a/pkg/plugin/implementation/policyenforcer/config.go b/pkg/plugin/implementation/policyenforcer/config.go index 7232b3b..1bdeca7 100644 --- a/pkg/plugin/implementation/policyenforcer/config.go +++ b/pkg/plugin/implementation/policyenforcer/config.go @@ -2,6 +2,7 @@ package policyenforcer import ( "fmt" + "os" "strings" ) @@ -20,11 +21,12 @@ type Config struct { PolicyUrls []string // Query is the Rego query that returns a set of violation strings. - // Default: "data.policy.violations" + // Default: "data.policy.violations". Query string // Actions is the list of beckn actions to enforce policies on. - // Default: ["confirm"] + // When empty or nil, all actions are considered and the Rego policy + // is responsible for deciding which actions to gate. Actions []string // Enabled controls whether the plugin is active. @@ -53,7 +55,6 @@ var knownKeys = map[string]bool{ func DefaultConfig() *Config { return &Config{ Query: "data.policy.violations", - Actions: []string{"confirm"}, Enabled: true, DebugLogging: false, RuntimeConfig: make(map[string]string), @@ -71,7 +72,7 @@ func ParseConfig(cfg map[string]string) (*Config, error) { config.PolicyFile = file } - // Legacy: comma-separated policyUrls + // Comma-separated policyUrls (supports URLs, local files, and directory paths) if urls, ok := cfg["policyUrls"]; ok && urls != "" { for _, u := range strings.Split(urls, ",") { u = strings.TrimSpace(u) @@ -82,7 +83,12 @@ func ParseConfig(cfg map[string]string) (*Config, error) { } if config.PolicyDir == "" && config.PolicyFile == "" && len(config.PolicyUrls) == 0 { - return nil, fmt.Errorf("at least one policy source is required (policyDir, policyFile, or policyUrls)") + // Fall back to the default ./policies directory if it exists on disk. + if info, err := os.Stat("./policies"); err == nil && info.IsDir() { + config.PolicyDir = "./policies" + } else { + return nil, fmt.Errorf("at least one policy source is required (policyDir, policyFile, or policyUrls)") + } } if query, ok := cfg["query"]; ok && query != "" { @@ -119,7 +125,12 @@ func ParseConfig(cfg map[string]string) (*Config, error) { } // IsActionEnabled checks if the given action is in the configured actions list. +// When the actions list is empty/nil, all actions are enabled and action-gating +// is delegated entirely to the Rego policy. func (c *Config) IsActionEnabled(action string) bool { + if len(c.Actions) == 0 { + return true + } for _, a := range c.Actions { if a == action { return true diff --git a/pkg/plugin/implementation/policyenforcer/enforcer_test.go b/pkg/plugin/implementation/policyenforcer/enforcer_test.go index 8f6f811..e8ff4d0 100644 --- a/pkg/plugin/implementation/policyenforcer/enforcer_test.go +++ b/pkg/plugin/implementation/policyenforcer/enforcer_test.go @@ -47,10 +47,10 @@ func TestParseConfig_Defaults(t *testing.T) { t.Fatalf("unexpected error: %v", err) } if cfg.Query != "data.policy.violations" { - t.Errorf("expected default query, got %q", cfg.Query) + t.Errorf("expected default query 'data.policy.violations', got %q", cfg.Query) } - if len(cfg.Actions) != 1 || cfg.Actions[0] != "confirm" { - t.Errorf("expected default actions [confirm], got %v", cfg.Actions) + if len(cfg.Actions) != 0 { + t.Errorf("expected empty default actions (all enabled), got %v", cfg.Actions) } if !cfg.Enabled { t.Error("expected enabled=true by default") @@ -381,6 +381,7 @@ violations contains "blocked" if { input.context.action == "confirm"; input.bloc enforcer, err := New(map[string]string{ "policyDir": dir, + "query": "data.policy.violations", "actions": "confirm", }) if err != nil { @@ -404,6 +405,7 @@ violations contains "blocked" if { input.context.action == "confirm" } enforcer, err := New(map[string]string{ "policyDir": dir, + "query": "data.policy.violations", "actions": "confirm", }) if err != nil { @@ -432,6 +434,7 @@ violations contains "blocked" if { true } enforcer, err := New(map[string]string{ "policyDir": dir, + "query": "data.policy.violations", "actions": "confirm", }) if err != nil { @@ -456,6 +459,7 @@ violations contains "blocked" if { true } enforcer, err := New(map[string]string{ "policyDir": dir, + "query": "data.policy.violations", "enabled": "false", }) if err != nil { @@ -484,6 +488,7 @@ violations contains "blocked" if { input.context.action == "confirm" } enforcer, err := New(map[string]string{ "policyUrls": srv.URL + "/block_confirm.rego", + "query": "data.policy.violations", "actions": "confirm", }) if err != nil { diff --git a/pkg/plugin/implementation/policyenforcer/evaluator.go b/pkg/plugin/implementation/policyenforcer/evaluator.go index 8e93b71..f6e4807 100644 --- a/pkg/plugin/implementation/policyenforcer/evaluator.go +++ b/pkg/plugin/implementation/policyenforcer/evaluator.go @@ -78,7 +78,7 @@ func NewEvaluator(policyDir, policyFile string, policyUrls []string, query strin modules[filepath.Base(policyFile)] = string(data) } - // Load from URLs and local file paths (policyUrls) + // Load from URLs, local file paths, and directory paths (policyUrls) for _, rawSource := range policyUrls { if isURL(rawSource) { name, content, err := fetchPolicy(rawSource) @@ -86,6 +86,23 @@ func NewEvaluator(policyDir, policyFile string, policyUrls []string, query strin return nil, fmt.Errorf("failed to fetch policy from %s: %w", rawSource, err) } modules[name] = content + } else if info, err := os.Stat(rawSource); err == nil && info.IsDir() { + // Treat as directory — load all .rego files inside + entries, err := os.ReadDir(rawSource) + if err != nil { + return nil, fmt.Errorf("failed to read policy directory %s: %w", rawSource, err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".rego") || strings.HasSuffix(entry.Name(), "_test.rego") { + continue + } + fpath := filepath.Join(rawSource, entry.Name()) + data, err := os.ReadFile(fpath) + if err != nil { + return nil, fmt.Errorf("failed to read policy file %s: %w", fpath, err) + } + modules[entry.Name()] = string(data) + } } else { // Treat as local file path data, err := os.ReadFile(rawSource) From a806af32285e88ec1a78d0f7b067a091aefe9ebc Mon Sep 17 00:00:00 2001 From: Ayush Rawat Date: Tue, 3 Mar 2026 15:02:04 +0530 Subject: [PATCH 3/8] Update Policy Enforcer Configuration Keys - Changed configuration key from `policyDir` to `policyPaths` across multiple YAML files and related code to standardize the naming convention. - Updated documentation to reflect the new key name and its usage for specifying local directories containing `.rego` policy files. - Adjusted tests to ensure compatibility with the updated configuration structure. --- config/local-beckn-one-bap.yaml | 2 +- config/local-beckn-one-bpp.yaml | 2 +- config/local-simple.yaml | 4 +-- config/onix/adapter.yaml | 8 ++--- .../implementation/policyenforcer/README.md | 2 +- .../implementation/policyenforcer/config.go | 18 +++++----- .../implementation/policyenforcer/enforcer.go | 2 +- .../policyenforcer/enforcer_test.go | 34 +++++++++---------- .../policyenforcer/evaluator.go | 10 +++--- 9 files changed, 41 insertions(+), 41 deletions(-) diff --git a/config/local-beckn-one-bap.yaml b/config/local-beckn-one-bap.yaml index 84f6090..f9b12b9 100644 --- a/config/local-beckn-one-bap.yaml +++ b/config/local-beckn-one-bap.yaml @@ -89,7 +89,7 @@ modules: policyEnforcer: id: policyenforcer config: - policyDir: "./policies" + policyPaths: "./policies" middleware: - id: reqpreprocessor config: diff --git a/config/local-beckn-one-bpp.yaml b/config/local-beckn-one-bpp.yaml index 812c588..956b893 100644 --- a/config/local-beckn-one-bpp.yaml +++ b/config/local-beckn-one-bpp.yaml @@ -87,7 +87,7 @@ modules: policyEnforcer: id: policyenforcer config: - policyDir: "./policies" + policyPaths: "./policies" steps: - validateSign - policyEnforcer diff --git a/config/local-simple.yaml b/config/local-simple.yaml index 270d046..2888a27 100644 --- a/config/local-simple.yaml +++ b/config/local-simple.yaml @@ -69,7 +69,7 @@ modules: policyEnforcer: id: policyenforcer config: - policyDir: "./policies" + policyPaths: "./policies" middleware: - id: reqpreprocessor config: @@ -170,7 +170,7 @@ modules: policyEnforcer: id: policyenforcer config: - policyDir: "./policies" + policyPaths: "./policies" steps: - validateSign - policyEnforcer diff --git a/config/onix/adapter.yaml b/config/onix/adapter.yaml index 77485af..d838f64 100644 --- a/config/onix/adapter.yaml +++ b/config/onix/adapter.yaml @@ -51,7 +51,7 @@ modules: policyEnforcer: id: policyenforcer config: - policyDir: "./policies" + policyPaths: "./policies" signValidator: id: signvalidator publisher: @@ -106,7 +106,7 @@ modules: policyEnforcer: id: policyenforcer config: - policyDir: "./policies" + policyPaths: "./policies" signer: id: signer publisher: @@ -162,7 +162,7 @@ modules: policyEnforcer: id: policyenforcer config: - policyDir: "./policies" + policyPaths: "./policies" signValidator: id: signvalidator publisher: @@ -217,7 +217,7 @@ modules: policyEnforcer: id: policyenforcer config: - policyDir: "./policies" + policyPaths: "./policies" signer: id: signer publisher: diff --git a/pkg/plugin/implementation/policyenforcer/README.md b/pkg/plugin/implementation/policyenforcer/README.md index df77fab..dd94f45 100644 --- a/pkg/plugin/implementation/policyenforcer/README.md +++ b/pkg/plugin/implementation/policyenforcer/README.md @@ -18,7 +18,7 @@ All config keys are passed via `map[string]string` in the adapter YAML config. | Key | Required | Default | Description | |-----|----------|---------|-------------| | `policyUrls` | At least one of `policyUrls`, `policyDir`, or `policyFile` required | — | Comma-separated list of URLs, local file paths, or directory paths to `.rego` files | -| `policyDir` | | `./policies` | Local directory containing `.rego` files | +| `policyPaths` | | `./policies` | Local directory or path containing `.rego` files | | `policyFile` | | — | Single local `.rego` file path | | `query` | No | `data.policy.violations` | Rego query returning violation strings | | `actions` | No | *(empty — all actions)* | Comma-separated beckn actions to enforce. When omitted, all actions are evaluated and the Rego policy itself decides which to gate. | diff --git a/pkg/plugin/implementation/policyenforcer/config.go b/pkg/plugin/implementation/policyenforcer/config.go index 1bdeca7..52351f5 100644 --- a/pkg/plugin/implementation/policyenforcer/config.go +++ b/pkg/plugin/implementation/policyenforcer/config.go @@ -8,9 +8,9 @@ import ( // Config holds the configuration for the Policy Enforcer plugin. type Config struct { - // PolicyDir is a local directory containing .rego policy files (all loaded). - // At least one policy source (PolicyDir, PolicyFile, or PolicyUrls) is required. - PolicyDir string + // PolicyPaths is a local directory containing .rego policy files (all loaded). + // At least one policy source (PolicyPaths, PolicyFile, or PolicyUrls) is required. + PolicyPaths string // PolicyFile is a single local .rego file path. PolicyFile string @@ -42,7 +42,7 @@ type Config struct { // Known config keys that are handled directly (not forwarded to RuntimeConfig). var knownKeys = map[string]bool{ - "policyDir": true, + "policyPaths": true, "policyFile": true, "policyUrls": true, "query": true, @@ -65,8 +65,8 @@ func DefaultConfig() *Config { func ParseConfig(cfg map[string]string) (*Config, error) { config := DefaultConfig() - if dir, ok := cfg["policyDir"]; ok && dir != "" { - config.PolicyDir = dir + if dir, ok := cfg["policyPaths"]; ok && dir != "" { + config.PolicyPaths = dir } if file, ok := cfg["policyFile"]; ok && file != "" { config.PolicyFile = file @@ -82,12 +82,12 @@ func ParseConfig(cfg map[string]string) (*Config, error) { } } - if config.PolicyDir == "" && config.PolicyFile == "" && len(config.PolicyUrls) == 0 { + if config.PolicyPaths == "" && config.PolicyFile == "" && len(config.PolicyUrls) == 0 { // Fall back to the default ./policies directory if it exists on disk. if info, err := os.Stat("./policies"); err == nil && info.IsDir() { - config.PolicyDir = "./policies" + config.PolicyPaths = "./policies" } else { - return nil, fmt.Errorf("at least one policy source is required (policyDir, policyFile, or policyUrls)") + return nil, fmt.Errorf("at least one policy source is required (policyPaths, policyFile, or policyUrls)") } } diff --git a/pkg/plugin/implementation/policyenforcer/enforcer.go b/pkg/plugin/implementation/policyenforcer/enforcer.go index 194c72f..4826acd 100644 --- a/pkg/plugin/implementation/policyenforcer/enforcer.go +++ b/pkg/plugin/implementation/policyenforcer/enforcer.go @@ -24,7 +24,7 @@ func New(cfg map[string]string) (*PolicyEnforcer, error) { return nil, fmt.Errorf("policyenforcer: config error: %w", err) } - evaluator, err := NewEvaluator(config.PolicyDir, config.PolicyFile, config.PolicyUrls, config.Query, config.RuntimeConfig) + evaluator, err := NewEvaluator(config.PolicyPaths, config.PolicyFile, config.PolicyUrls, config.Query, config.RuntimeConfig) if err != nil { return nil, fmt.Errorf("policyenforcer: failed to initialize OPA evaluator: %w", err) } diff --git a/pkg/plugin/implementation/policyenforcer/enforcer_test.go b/pkg/plugin/implementation/policyenforcer/enforcer_test.go index e8ff4d0..4afd8cb 100644 --- a/pkg/plugin/implementation/policyenforcer/enforcer_test.go +++ b/pkg/plugin/implementation/policyenforcer/enforcer_test.go @@ -37,12 +37,12 @@ func writePolicyDir(t *testing.T, filename, content string) string { func TestParseConfig_RequiresPolicySource(t *testing.T) { _, err := ParseConfig(map[string]string{}) if err == nil { - t.Fatal("expected error when no policyDir, policyFile, or policyUrls given") + t.Fatal("expected error when no policyPaths, policyFile, or policyUrls given") } } func TestParseConfig_Defaults(t *testing.T) { - cfg, err := ParseConfig(map[string]string{"policyDir": "/tmp"}) + cfg, err := ParseConfig(map[string]string{"policyPaths": "/tmp"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -59,7 +59,7 @@ func TestParseConfig_Defaults(t *testing.T) { func TestParseConfig_RuntimeConfigForwarding(t *testing.T) { cfg, err := ParseConfig(map[string]string{ - "policyDir": "/tmp", + "policyPaths": "/tmp", "minDeliveryLeadHours": "6", "customParam": "value", }) @@ -76,8 +76,8 @@ func TestParseConfig_RuntimeConfigForwarding(t *testing.T) { func TestParseConfig_CustomActions(t *testing.T) { cfg, err := ParseConfig(map[string]string{ - "policyDir": "/tmp", - "actions": "confirm, select, init", + "policyPaths": "/tmp", + "actions": "confirm, select, init", }) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -380,9 +380,9 @@ violations contains "blocked" if { input.context.action == "confirm"; input.bloc dir := writePolicyDir(t, "test.rego", policy) enforcer, err := New(map[string]string{ - "policyDir": dir, - "query": "data.policy.violations", - "actions": "confirm", + "policyPaths": dir, + "query": "data.policy.violations", + "actions": "confirm", }) if err != nil { t.Fatalf("New failed: %v", err) @@ -404,9 +404,9 @@ violations contains "blocked" if { input.context.action == "confirm" } dir := writePolicyDir(t, "test.rego", policy) enforcer, err := New(map[string]string{ - "policyDir": dir, - "query": "data.policy.violations", - "actions": "confirm", + "policyPaths": dir, + "query": "data.policy.violations", + "actions": "confirm", }) if err != nil { t.Fatalf("New failed: %v", err) @@ -433,9 +433,9 @@ violations contains "blocked" if { true } dir := writePolicyDir(t, "test.rego", policy) enforcer, err := New(map[string]string{ - "policyDir": dir, - "query": "data.policy.violations", - "actions": "confirm", + "policyPaths": dir, + "query": "data.policy.violations", + "actions": "confirm", }) if err != nil { t.Fatalf("New failed: %v", err) @@ -458,9 +458,9 @@ violations contains "blocked" if { true } dir := writePolicyDir(t, "test.rego", policy) enforcer, err := New(map[string]string{ - "policyDir": dir, - "query": "data.policy.violations", - "enabled": "false", + "policyPaths": dir, + "query": "data.policy.violations", + "enabled": "false", }) if err != nil { t.Fatalf("New failed: %v", err) diff --git a/pkg/plugin/implementation/policyenforcer/evaluator.go b/pkg/plugin/implementation/policyenforcer/evaluator.go index f6e4807..7905c6f 100644 --- a/pkg/plugin/implementation/policyenforcer/evaluator.go +++ b/pkg/plugin/implementation/policyenforcer/evaluator.go @@ -40,14 +40,14 @@ const maxPolicySize = 1 << 20 // NewEvaluator creates an Evaluator by loading .rego files from local paths // and/or URLs, then compiling them. runtimeConfig is passed to Rego as data.config. -func NewEvaluator(policyDir, policyFile string, policyUrls []string, query string, runtimeConfig map[string]string) (*Evaluator, error) { +func NewEvaluator(policyPaths, policyFile string, policyUrls []string, query string, runtimeConfig map[string]string) (*Evaluator, error) { modules := make(map[string]string) // Load from local directory - if policyDir != "" { - entries, err := os.ReadDir(policyDir) + if policyPaths != "" { + entries, err := os.ReadDir(policyPaths) if err != nil { - return nil, fmt.Errorf("failed to read policy directory %s: %w", policyDir, err) + return nil, fmt.Errorf("failed to read policy directory %s: %w", policyPaths, err) } for _, entry := range entries { if entry.IsDir() { @@ -60,7 +60,7 @@ func NewEvaluator(policyDir, policyFile string, policyUrls []string, query strin if strings.HasSuffix(entry.Name(), "_test.rego") { continue } - fpath := filepath.Join(policyDir, entry.Name()) + fpath := filepath.Join(policyPaths, entry.Name()) data, err := os.ReadFile(fpath) if err != nil { return nil, fmt.Errorf("failed to read policy file %s: %w", fpath, err) From dfbaf5c6c5e8bedcd95993a9d6b3573a6496254c Mon Sep 17 00:00:00 2001 From: Ayush Rawat Date: Tue, 3 Mar 2026 18:49:17 +0530 Subject: [PATCH 4/8] Refactor Policy Enforcer to Use Unified PolicyPaths - Updated the Policy Enforcer to consolidate policy source configuration under a single `policyPaths` key, allowing for auto-detection of URLs, directories, and files. - Removed deprecated keys such as `policyFile` and `policyUrls` from the configuration structure. - Adjusted related code and tests to ensure compatibility with the new configuration format. - Enhanced documentation to clarify the usage of `policyPaths` and provide examples for various configurations. --- config/onix/adapter.yaml | 10 +++ .../implementation/policyenforcer/README.md | 42 +++++++----- .../implementation/policyenforcer/config.go | 44 +++++-------- .../implementation/policyenforcer/enforcer.go | 2 +- .../policyenforcer/enforcer_test.go | 43 ++++++------ .../policyenforcer/evaluator.go | 65 +++++-------------- 6 files changed, 86 insertions(+), 120 deletions(-) diff --git a/config/onix/adapter.yaml b/config/onix/adapter.yaml index d838f64..93985b3 100644 --- a/config/onix/adapter.yaml +++ b/config/onix/adapter.yaml @@ -51,6 +51,16 @@ modules: policyEnforcer: id: policyenforcer config: + # policyPaths: polymorphic, auto-detects each entry as URL, directory, or file + # Examples: + # policyPaths: "./policies" # local directory + # policyPaths: "https://example.com/compliance.rego" # remote URL + # policyPaths: "./policies/compliance.rego" # local file + # For multiple sources, use YAML folded scalar (>-): + # policyPaths: >- + # https://example.com/compliance.rego, + # https://example.com/safety.rego, + # ./policies policyPaths: "./policies" signValidator: id: signvalidator diff --git a/pkg/plugin/implementation/policyenforcer/README.md b/pkg/plugin/implementation/policyenforcer/README.md index dd94f45..cd64462 100644 --- a/pkg/plugin/implementation/policyenforcer/README.md +++ b/pkg/plugin/implementation/policyenforcer/README.md @@ -5,7 +5,7 @@ OPA/Rego-based policy enforcement for beckn-onix adapters. Evaluates incoming be ## Overview The `policyenforcer` plugin is a **Step plugin** that: -- Loads `.rego` policy files from local directories, files, URLs, or local paths +- Loads `.rego` policy files from URLs, local directories, or local files - Evaluates incoming messages against compiled OPA policies - Returns a `BadReqErr` (NACK) when policy violations are detected - Fails closed on evaluation errors (treats as NACK) @@ -17,9 +17,7 @@ All config keys are passed via `map[string]string` in the adapter YAML config. | Key | Required | Default | Description | |-----|----------|---------|-------------| -| `policyUrls` | At least one of `policyUrls`, `policyDir`, or `policyFile` required | — | Comma-separated list of URLs, local file paths, or directory paths to `.rego` files | -| `policyPaths` | | `./policies` | Local directory or path containing `.rego` files | -| `policyFile` | | — | Single local `.rego` file path | +| `policyPaths` | Yes (at least one source required) | `./policies` (if dir exists) | Comma-separated list of policy sources — each entry is auto-detected as a **URL**, **directory**, or **file** | | `query` | No | `data.policy.violations` | Rego query returning violation strings | | `actions` | No | *(empty — all actions)* | Comma-separated beckn actions to enforce. When omitted, all actions are evaluated and the Rego policy itself decides which to gate. | | `enabled` | No | `true` | Enable/disable the plugin | @@ -28,26 +26,34 @@ All config keys are passed via `map[string]string` in the adapter YAML config. ### Policy Sources -`policyUrls` is the primary configuration key. It accepts a comma-separated list of: -- **Remote URLs**: `https://policies.example.com/compliance.rego` -- **Local file paths**: `/etc/policies/local.rego` -- **Directory paths**: `/etc/policies/` (all `.rego` files loaded, `_test.rego` excluded) +`policyPaths` is the single configuration key for all policy sources. Each comma-separated entry is **auto-detected** as: +- **Remote URL** (`http://` or `https://`): fetched via HTTP at startup +- **Local directory**: all `.rego` files loaded (`_test.rego` excluded) +- **Local file**: loaded directly ```yaml +# Single directory config: - policyUrls: "https://policies.example.com/compliance.rego,/etc/policies/,/local/safety.rego" + policyPaths: "./policies" + +# Single remote URL +config: + policyPaths: "https://policies.example.com/compliance.rego" + +# Mix of URLs, directories, and files +config: + policyPaths: "https://policies.example.com/compliance.rego,./policies,/local/safety.rego" ``` -When specifying many URLs, use the YAML folded scalar (`>-`) to keep the config readable: +When specifying many sources, use the YAML folded scalar (`>-`) to keep the config readable: ```yaml config: - policyUrls: >- + policyPaths: >- https://policies.example.com/compliance.rego, https://policies.example.com/safety.rego, - https://policies.example.com/rate-limit.rego, - /local/policies/, - https://policies.example.com/auth.rego + ./policies, + /local/overrides/rate-limit.rego ``` The `>-` folds newlines into spaces, so the value is parsed as a single comma-separated string. @@ -68,7 +74,7 @@ Or specify a custom policy location: policyEnforcer: id: policyenforcer config: - policyUrls: "./policies/compliance.rego" + policyPaths: "./policies/compliance.rego" ``` ### Air-Gapped Deployments @@ -77,7 +83,7 @@ For environments without internet access, use local file paths or volume mounts: ```yaml config: - policyUrls: "/mounted-policies/compliance.rego,/mounted-policies/safety.rego" + policyPaths: "/mounted-policies/compliance.rego,/mounted-policies/safety.rego" ``` ## Example Config @@ -87,7 +93,9 @@ plugins: policyEnforcer: id: policyenforcer config: - policyUrls: "https://policies.example.com/compliance.rego,/local/policies/" + policyPaths: >- + /local/policies/, + https://policies.example.com/compliance.rego minDeliveryLeadHours: "4" debugLogging: "true" steps: diff --git a/pkg/plugin/implementation/policyenforcer/config.go b/pkg/plugin/implementation/policyenforcer/config.go index 52351f5..10d44d8 100644 --- a/pkg/plugin/implementation/policyenforcer/config.go +++ b/pkg/plugin/implementation/policyenforcer/config.go @@ -8,17 +8,12 @@ import ( // Config holds the configuration for the Policy Enforcer plugin. type Config struct { - // PolicyPaths is a local directory containing .rego policy files (all loaded). - // At least one policy source (PolicyPaths, PolicyFile, or PolicyUrls) is required. - PolicyPaths string - - // PolicyFile is a single local .rego file path. - PolicyFile string - - // PolicyUrls is a list of URLs (or local file paths) pointing to .rego files, - // fetched at startup or read from disk. - // Parsed from the comma-separated "policyUrls" config key. - PolicyUrls []string + // PolicyPaths is a list of policy sources. Each entry is auto-detected as: + // - Remote URL (http:// or https://) → fetched via HTTP + // - Local directory → all .rego files loaded (excluding _test.rego) + // - Local file → loaded directly + // Parsed from the comma-separated "policyPaths" config key. + PolicyPaths []string // Query is the Rego query that returns a set of violation strings. // Default: "data.policy.violations". @@ -43,8 +38,6 @@ type Config struct { // Known config keys that are handled directly (not forwarded to RuntimeConfig). var knownKeys = map[string]bool{ "policyPaths": true, - "policyFile": true, - "policyUrls": true, "query": true, "actions": true, "enabled": true, @@ -65,29 +58,22 @@ func DefaultConfig() *Config { func ParseConfig(cfg map[string]string) (*Config, error) { config := DefaultConfig() - if dir, ok := cfg["policyPaths"]; ok && dir != "" { - config.PolicyPaths = dir - } - if file, ok := cfg["policyFile"]; ok && file != "" { - config.PolicyFile = file - } - - // Comma-separated policyUrls (supports URLs, local files, and directory paths) - if urls, ok := cfg["policyUrls"]; ok && urls != "" { - for _, u := range strings.Split(urls, ",") { - u = strings.TrimSpace(u) - if u != "" { - config.PolicyUrls = append(config.PolicyUrls, u) + // Comma-separated policyPaths (each entry auto-detected as URL, directory, or file) + if paths, ok := cfg["policyPaths"]; ok && paths != "" { + for _, p := range strings.Split(paths, ",") { + p = strings.TrimSpace(p) + if p != "" { + config.PolicyPaths = append(config.PolicyPaths, p) } } } - if config.PolicyPaths == "" && config.PolicyFile == "" && len(config.PolicyUrls) == 0 { + if len(config.PolicyPaths) == 0 { // Fall back to the default ./policies directory if it exists on disk. if info, err := os.Stat("./policies"); err == nil && info.IsDir() { - config.PolicyPaths = "./policies" + config.PolicyPaths = append(config.PolicyPaths, "./policies") } else { - return nil, fmt.Errorf("at least one policy source is required (policyPaths, policyFile, or policyUrls)") + return nil, fmt.Errorf("at least one policy source is required (policyPaths)") } } diff --git a/pkg/plugin/implementation/policyenforcer/enforcer.go b/pkg/plugin/implementation/policyenforcer/enforcer.go index 4826acd..97c74cb 100644 --- a/pkg/plugin/implementation/policyenforcer/enforcer.go +++ b/pkg/plugin/implementation/policyenforcer/enforcer.go @@ -24,7 +24,7 @@ func New(cfg map[string]string) (*PolicyEnforcer, error) { return nil, fmt.Errorf("policyenforcer: config error: %w", err) } - evaluator, err := NewEvaluator(config.PolicyPaths, config.PolicyFile, config.PolicyUrls, config.Query, config.RuntimeConfig) + evaluator, err := NewEvaluator(config.PolicyPaths, config.Query, config.RuntimeConfig) if err != nil { return nil, fmt.Errorf("policyenforcer: failed to initialize OPA evaluator: %w", err) } diff --git a/pkg/plugin/implementation/policyenforcer/enforcer_test.go b/pkg/plugin/implementation/policyenforcer/enforcer_test.go index 4afd8cb..15ac2a3 100644 --- a/pkg/plugin/implementation/policyenforcer/enforcer_test.go +++ b/pkg/plugin/implementation/policyenforcer/enforcer_test.go @@ -37,7 +37,7 @@ func writePolicyDir(t *testing.T, filename, content string) string { func TestParseConfig_RequiresPolicySource(t *testing.T) { _, err := ParseConfig(map[string]string{}) if err == nil { - t.Fatal("expected error when no policyPaths, policyFile, or policyUrls given") + t.Fatal("expected error when no policyPaths given") } } @@ -93,24 +93,21 @@ func TestParseConfig_CustomActions(t *testing.T) { } } -func TestParseConfig_PolicyUrls(t *testing.T) { +func TestParseConfig_PolicyPaths(t *testing.T) { cfg, err := ParseConfig(map[string]string{ - "policyUrls": "https://example.com/a.rego, https://example.com/b.rego", + "policyPaths": "https://example.com/a.rego, https://example.com/b.rego", }) if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(cfg.PolicyUrls) != 2 { - t.Fatalf("expected 2 URLs, got %d: %v", len(cfg.PolicyUrls), cfg.PolicyUrls) + if len(cfg.PolicyPaths) != 2 { + t.Fatalf("expected 2 paths, got %d: %v", len(cfg.PolicyPaths), cfg.PolicyPaths) } - if cfg.PolicyUrls[0] != "https://example.com/a.rego" { - t.Errorf("url[0] = %q", cfg.PolicyUrls[0]) + if cfg.PolicyPaths[0] != "https://example.com/a.rego" { + t.Errorf("path[0] = %q", cfg.PolicyPaths[0]) } } -// Note: policySources support was removed; we intentionally only support -// comma-separated policyUrls and local paths via policyUrls entries. - // --- Evaluator Tests (with inline policies) --- func TestEvaluator_NoViolations(t *testing.T) { @@ -123,7 +120,7 @@ violations contains msg if { } ` dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator(dir, "", nil, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -147,7 +144,7 @@ violations contains msg if { } ` dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator(dir, "", nil, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -174,7 +171,7 @@ violations contains msg if { } ` dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator(dir, "", nil, "data.policy.violations", map[string]string{"maxValue": "100"}) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", map[string]string{"maxValue": "100"}) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -217,7 +214,7 @@ test_something if { count(policy.violations) > 0 } ` os.WriteFile(filepath.Join(dir, "policy_test.rego"), []byte(testFile), 0644) - eval, err := NewEvaluator(dir, "", nil, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) if err != nil { t.Fatalf("NewEvaluator should skip _test.rego files, but failed: %v", err) } @@ -238,7 +235,7 @@ import rego.v1 violations := set() ` dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator(dir, "", nil, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -267,7 +264,7 @@ violations contains msg if { })) defer srv.Close() - eval, err := NewEvaluator("", "", []string{srv.URL + "/test_policy.rego"}, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{srv.URL + "/test_policy.rego"}, "data.policy.violations", nil) if err != nil { t.Fatalf("NewEvaluator with URL failed: %v", err) } @@ -295,14 +292,14 @@ func TestEvaluator_FetchURL_NotFound(t *testing.T) { srv := httptest.NewServer(http.NotFoundHandler()) defer srv.Close() - _, err := NewEvaluator("", "", []string{srv.URL + "/missing.rego"}, "data.policy.violations", nil) + _, err := NewEvaluator([]string{srv.URL + "/missing.rego"}, "data.policy.violations", nil) if err == nil { t.Fatal("expected error for 404 URL") } } func TestEvaluator_FetchURL_InvalidScheme(t *testing.T) { - _, err := NewEvaluator("", "", []string{"ftp://example.com/policy.rego"}, "data.policy.violations", nil) + _, err := NewEvaluator([]string{"ftp://example.com/policy.rego"}, "data.policy.violations", nil) if err == nil { t.Fatal("expected error for ftp:// scheme") } @@ -328,7 +325,7 @@ violations contains "remote_violation" if { input.remote_bad } })) defer srv.Close() - eval, err := NewEvaluator(dir, "", []string{srv.URL + "/remote.rego"}, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{dir, srv.URL + "/remote.rego"}, "data.policy.violations", nil) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -355,7 +352,7 @@ violations contains "from_file" if { input.bad } policyPath := filepath.Join(dir, "local_policy.rego") os.WriteFile(policyPath, []byte(policy), 0644) - eval, err := NewEvaluator("", "", []string{policyPath}, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{policyPath}, "data.policy.violations", nil) if err != nil { t.Fatalf("NewEvaluator with local path failed: %v", err) } @@ -487,9 +484,9 @@ violations contains "blocked" if { input.context.action == "confirm" } defer srv.Close() enforcer, err := New(map[string]string{ - "policyUrls": srv.URL + "/block_confirm.rego", - "query": "data.policy.violations", - "actions": "confirm", + "policyPaths": srv.URL + "/block_confirm.rego", + "query": "data.policy.violations", + "actions": "confirm", }) if err != nil { t.Fatalf("New failed: %v", err) diff --git a/pkg/plugin/implementation/policyenforcer/evaluator.go b/pkg/plugin/implementation/policyenforcer/evaluator.go index 7905c6f..fe3d84b 100644 --- a/pkg/plugin/implementation/policyenforcer/evaluator.go +++ b/pkg/plugin/implementation/policyenforcer/evaluator.go @@ -40,63 +40,28 @@ const maxPolicySize = 1 << 20 // NewEvaluator creates an Evaluator by loading .rego files from local paths // and/or URLs, then compiling them. runtimeConfig is passed to Rego as data.config. -func NewEvaluator(policyPaths, policyFile string, policyUrls []string, query string, runtimeConfig map[string]string) (*Evaluator, error) { +func NewEvaluator(policyPaths []string, query string, runtimeConfig map[string]string) (*Evaluator, error) { modules := make(map[string]string) - // Load from local directory - if policyPaths != "" { - entries, err := os.ReadDir(policyPaths) - if err != nil { - return nil, fmt.Errorf("failed to read policy directory %s: %w", policyPaths, err) - } - for _, entry := range entries { - if entry.IsDir() { - continue - } - if !strings.HasSuffix(entry.Name(), ".rego") { - continue - } - // Skip test files — they shouldn't be compiled into the runtime evaluator - if strings.HasSuffix(entry.Name(), "_test.rego") { - continue - } - fpath := filepath.Join(policyPaths, entry.Name()) - data, err := os.ReadFile(fpath) + // Load from policyPaths (each entry auto-detected as URL, directory, or file) + for _, source := range policyPaths { + if isURL(source) { + name, content, err := fetchPolicy(source) if err != nil { - return nil, fmt.Errorf("failed to read policy file %s: %w", fpath, err) - } - modules[entry.Name()] = string(data) - } - } - - // Load single local file - if policyFile != "" { - data, err := os.ReadFile(policyFile) - if err != nil { - return nil, fmt.Errorf("failed to read policy file %s: %w", policyFile, err) - } - modules[filepath.Base(policyFile)] = string(data) - } - - // Load from URLs, local file paths, and directory paths (policyUrls) - for _, rawSource := range policyUrls { - if isURL(rawSource) { - name, content, err := fetchPolicy(rawSource) - if err != nil { - return nil, fmt.Errorf("failed to fetch policy from %s: %w", rawSource, err) + return nil, fmt.Errorf("failed to fetch policy from %s: %w", source, err) } modules[name] = content - } else if info, err := os.Stat(rawSource); err == nil && info.IsDir() { - // Treat as directory — load all .rego files inside - entries, err := os.ReadDir(rawSource) + } else if info, err := os.Stat(source); err == nil && info.IsDir() { + // Directory — load all .rego files inside + entries, err := os.ReadDir(source) if err != nil { - return nil, fmt.Errorf("failed to read policy directory %s: %w", rawSource, err) + return nil, fmt.Errorf("failed to read policy directory %s: %w", source, err) } for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".rego") || strings.HasSuffix(entry.Name(), "_test.rego") { continue } - fpath := filepath.Join(rawSource, entry.Name()) + fpath := filepath.Join(source, entry.Name()) data, err := os.ReadFile(fpath) if err != nil { return nil, fmt.Errorf("failed to read policy file %s: %w", fpath, err) @@ -104,12 +69,12 @@ func NewEvaluator(policyPaths, policyFile string, policyUrls []string, query str modules[entry.Name()] = string(data) } } else { - // Treat as local file path - data, err := os.ReadFile(rawSource) + // Local file path + data, err := os.ReadFile(source) if err != nil { - return nil, fmt.Errorf("failed to read local policy source %s: %w", rawSource, err) + return nil, fmt.Errorf("failed to read policy file %s: %w", source, err) } - modules[filepath.Base(rawSource)] = string(data) + modules[filepath.Base(source)] = string(data) } } From ff4d909b7ee83fbdfe83fa0c1aa19dee168f352a Mon Sep 17 00:00:00 2001 From: Ayush Rawat Date: Thu, 5 Mar 2026 15:31:34 +0530 Subject: [PATCH 5/8] Enhance Policy Enforcer Configuration and Add Benchmark Tests - Added detailed comments and examples for the `policyPaths` configuration in both BAP and BPP YAML files to improve clarity on usage. - Introduced a new benchmark test suite for the Policy Enforcer to evaluate performance under varying rule counts, measuring both evaluation and compilation times. --- config/local-beckn-one-bap.yaml | 15 + config/local-beckn-one-bpp.yaml | 15 + .../policyenforcer/benchmark_test.go | 308 ++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 pkg/plugin/implementation/policyenforcer/benchmark_test.go diff --git a/config/local-beckn-one-bap.yaml b/config/local-beckn-one-bap.yaml index f9b12b9..1a745f5 100644 --- a/config/local-beckn-one-bap.yaml +++ b/config/local-beckn-one-bap.yaml @@ -89,6 +89,16 @@ modules: policyEnforcer: id: policyenforcer config: + # policyPaths: polymorphic, auto-detects each entry as URL, directory, or file + # Examples: + # policyPaths: "./policies" # local directory + # policyPaths: "https://example.com/compliance.rego" # remote URL + # policyPaths: "./policies/compliance.rego" # local file + # For multiple sources, use YAML folded scalar (>-): + # policyPaths: >- + # https://example.com/compliance.rego, + # https://example.com/safety.rego, + # ./policies policyPaths: "./policies" middleware: - id: reqpreprocessor @@ -156,7 +166,12 @@ modules: config: contextKeys: transaction_id,message_id role: bap + policyEnforcer: + id: policyenforcer + config: + policyPaths: "./policies" steps: + - policyEnforcer - addRoute - sign - validateSchema diff --git a/config/local-beckn-one-bpp.yaml b/config/local-beckn-one-bpp.yaml index 956b893..dece530 100644 --- a/config/local-beckn-one-bpp.yaml +++ b/config/local-beckn-one-bpp.yaml @@ -87,6 +87,16 @@ modules: policyEnforcer: id: policyenforcer config: + # policyPaths: polymorphic, auto-detects each entry as URL, directory, or file + # Examples: + # policyPaths: "./policies" # local directory + # policyPaths: "https://example.com/compliance.rego" # remote URL + # policyPaths: "./policies/compliance.rego" # local file + # For multiple sources, use YAML folded scalar (>-): + # policyPaths: >- + # https://example.com/compliance.rego, + # https://example.com/safety.rego, + # ./policies policyPaths: "./policies" steps: - validateSign @@ -149,7 +159,12 @@ modules: config: contextKeys: transaction_id,message_id role: bpp + policyEnforcer: + id: policyenforcer + config: + policyPaths: "./policies" steps: + - policyEnforcer - addRoute - sign - validateSchema diff --git a/pkg/plugin/implementation/policyenforcer/benchmark_test.go b/pkg/plugin/implementation/policyenforcer/benchmark_test.go new file mode 100644 index 0000000..f7cb1dd --- /dev/null +++ b/pkg/plugin/implementation/policyenforcer/benchmark_test.go @@ -0,0 +1,308 @@ +// Benchmarks for policy enforcer evaluation scaling. +// Measures how OPA evaluation time changes with rule count (1 to 500 rules), +// covering both realistic (mostly inactive) and worst-case (all active) scenarios. +// Also benchmarks compilation time (one-time startup cost). +// +// Run human-readable report: go test -run TestBenchmarkReport -v -count=1 +// Run Go benchmarks: go test -bench=. -benchmem -count=1 +package policyenforcer + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// generateDummyRules creates a .rego policy file with N rules. +// Only one rule matches the input (action == "confirm"), the rest have impossible +// conditions (action == "foobar1", "foobar2", ...) to simulate realistic rule bloat +// where most rules don't fire. +func generateDummyRules(n int) string { + var sb strings.Builder + sb.WriteString("package policy\nimport rego.v1\n\n") + + // One real rule that actually fires + sb.WriteString("violations contains \"real_violation\" if {\n") + sb.WriteString(" input.context.action == \"confirm\"\n") + sb.WriteString(" input.message.order.value > 10000\n") + sb.WriteString("}\n\n") + + // N-1 dummy rules with impossible conditions (simulate rule bloat) + for i := 1; i < n; i++ { + sb.WriteString(fmt.Sprintf("violations contains \"dummy_violation_%d\" if {\n", i)) + sb.WriteString(fmt.Sprintf(" input.context.action == \"foobar%d\"\n", i)) + sb.WriteString(fmt.Sprintf(" input.message.order.value > %d\n", i*100)) + sb.WriteString("}\n\n") + } + + return sb.String() +} + +// generateActiveRules creates N rules that ALL fire on the test input. +// This is the worst case: every rule matches. +func generateActiveRules(n int) string { + var sb strings.Builder + sb.WriteString("package policy\nimport rego.v1\n\n") + + for i := 0; i < n; i++ { + sb.WriteString(fmt.Sprintf("violations contains \"active_violation_%d\" if {\n", i)) + sb.WriteString(" input.context.action == \"confirm\"\n") + sb.WriteString("}\n\n") + } + + return sb.String() +} + +// sampleBecknInput is a realistic beckn confirm message for benchmarking. +var sampleBecknInput = []byte(`{ + "context": { + "domain": "energy", + "action": "confirm", + "version": "1.1.0", + "bap_id": "buyer-bap.example.com", + "bap_uri": "https://buyer-bap.example.com", + "bpp_id": "seller-bpp.example.com", + "bpp_uri": "https://seller-bpp.example.com", + "transaction_id": "txn-12345", + "message_id": "msg-67890", + "timestamp": "2026-03-04T10:00:00Z" + }, + "message": { + "order": { + "id": "order-001", + "provider": {"id": "seller-1"}, + "items": [ + {"id": "item-1", "quantity": {"selected": {"count": 100}}}, + {"id": "item-2", "quantity": {"selected": {"count": 50}}} + ], + "value": 15000, + "fulfillment": { + "type": "DELIVERY", + "start": {"time": {"timestamp": "2026-03-05T08:00:00Z"}}, + "end": {"time": {"timestamp": "2026-03-05T18:00:00Z"}} + } + } + } +}`) + +// --- Go Benchmarks (run with: go test -bench=. -benchmem) --- + +// BenchmarkEvaluate_MostlyInactive benchmarks evaluation with N rules where +// only 1 rule fires. This simulates a realistic governance ruleset where +// most rules are for different actions/conditions. +func BenchmarkEvaluate_MostlyInactive(b *testing.B) { + sizes := []int{1, 10, 50, 100, 250, 500} + for _, n := range sizes { + b.Run(fmt.Sprintf("rules=%d", n), func(b *testing.B) { + dir := b.TempDir() + os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644) + + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + if err != nil { + b.Fatalf("NewEvaluator failed: %v", err) + } + + ctx := context.Background() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := eval.Evaluate(ctx, sampleBecknInput) + if err != nil { + b.Fatalf("Evaluate failed: %v", err) + } + } + }) + } +} + +// BenchmarkEvaluate_AllActive benchmarks the worst case where ALL N rules fire. +func BenchmarkEvaluate_AllActive(b *testing.B) { + sizes := []int{1, 10, 50, 100, 250, 500} + for _, n := range sizes { + b.Run(fmt.Sprintf("rules=%d", n), func(b *testing.B) { + dir := b.TempDir() + os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateActiveRules(n)), 0644) + + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + if err != nil { + b.Fatalf("NewEvaluator failed: %v", err) + } + + ctx := context.Background() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := eval.Evaluate(ctx, sampleBecknInput) + if err != nil { + b.Fatalf("Evaluate failed: %v", err) + } + } + }) + } +} + +// BenchmarkCompilation measures how long it takes to compile policies of various sizes. +// This runs once at startup, so it's less critical but good to know. +func BenchmarkCompilation(b *testing.B) { + sizes := []int{10, 50, 100, 250, 500} + for _, n := range sizes { + b.Run(fmt.Sprintf("rules=%d", n), func(b *testing.B) { + dir := b.TempDir() + os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + if err != nil { + b.Fatalf("NewEvaluator failed: %v", err) + } + } + }) + } +} + +// --- Human-Readable Report (run with: go test -run TestBenchmarkReport -v) --- + +// TestBenchmarkReport generates a readable table showing how evaluation time +// scales with rule count. This is the report to share with the team. +func TestBenchmarkReport(t *testing.T) { + sizes := []int{1, 10, 50, 100, 250, 500} + iterations := 1000 + + fmt.Println() + fmt.Println("╔══════════════════════════════════════════════════════════════════════╗") + fmt.Println("║ Policy Enforcer — Performance Benchmark Report ║") + fmt.Println("╠══════════════════════════════════════════════════════════════════════╣") + fmt.Println() + + // --- Compilation time --- + fmt.Println("┌─────────────────────────────────────────────────┐") + fmt.Println("│ Compilation Time (one-time startup cost) │") + fmt.Println("├──────────┬──────────────────────────────────────┤") + fmt.Println("│ Rules │ Compilation Time │") + fmt.Println("├──────────┼──────────────────────────────────────┤") + for _, n := range sizes { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644) + + start := time.Now() + _, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + elapsed := time.Since(start) + if err != nil { + t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err) + } + fmt.Printf("│ %-8d │ %-36s │\n", n, elapsed.Round(time.Microsecond)) + } + fmt.Println("└──────────┴──────────────────────────────────────┘") + fmt.Println() + + // --- Evaluation time (mostly inactive rules) --- + fmt.Println("┌─────────────────────────────────────────────────────────────────┐") + fmt.Printf("│ Evaluation Time — Mostly Inactive Rules (%d iterations) │\n", iterations) + fmt.Println("│ (1 rule fires, rest have non-matching conditions) │") + fmt.Println("├──────────┬──────────────┬──────────────┬────────────────────────┤") + fmt.Println("│ Rules │ Avg/eval │ p99 │ Violations │") + fmt.Println("├──────────┼──────────────┼──────────────┼────────────────────────┤") + for _, n := range sizes { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644) + + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + if err != nil { + t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err) + } + + ctx := context.Background() + durations := make([]time.Duration, iterations) + var lastViolations []string + + for i := 0; i < iterations; i++ { + start := time.Now() + v, err := eval.Evaluate(ctx, sampleBecknInput) + durations[i] = time.Since(start) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + lastViolations = v + } + + avg, p99 := calcStats(durations) + fmt.Printf("│ %-8d │ %-12s │ %-12s │ %-22d │\n", n, avg.Round(time.Microsecond), p99.Round(time.Microsecond), len(lastViolations)) + } + fmt.Println("└──────────┴──────────────┴──────────────┴────────────────────────┘") + fmt.Println() + + // --- Evaluation time (all rules active) --- + fmt.Println("┌─────────────────────────────────────────────────────────────────┐") + fmt.Printf("│ Evaluation Time — All Rules Active (%d iterations) │\n", iterations) + fmt.Println("│ (every rule fires — worst case scenario) │") + fmt.Println("├──────────┬──────────────┬──────────────┬────────────────────────┤") + fmt.Println("│ Rules │ Avg/eval │ p99 │ Violations │") + fmt.Println("├──────────┼──────────────┼──────────────┼────────────────────────┤") + for _, n := range sizes { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateActiveRules(n)), 0644) + + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + if err != nil { + t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err) + } + + ctx := context.Background() + durations := make([]time.Duration, iterations) + var lastViolations []string + + for i := 0; i < iterations; i++ { + start := time.Now() + v, err := eval.Evaluate(ctx, sampleBecknInput) + durations[i] = time.Since(start) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + lastViolations = v + } + + avg, p99 := calcStats(durations) + fmt.Printf("│ %-8d │ %-12s │ %-12s │ %-22d │\n", n, avg.Round(time.Microsecond), p99.Round(time.Microsecond), len(lastViolations)) + } + fmt.Println("└──────────┴──────────────┴──────────────┴────────────────────────┘") + fmt.Println() + fmt.Println("╚══════════════════════════════════════════════════════════════════════╝") +} + +// calcStats returns average and p99 durations from a sorted slice. +func calcStats(durations []time.Duration) (avg, p99 time.Duration) { + n := len(durations) + if n == 0 { + return 0, 0 + } + + var total time.Duration + for _, d := range durations { + total += d + } + avg = total / time.Duration(n) + + // Sort for p99 + sorted := make([]time.Duration, n) + copy(sorted, durations) + sortDurations(sorted) + p99 = sorted[int(float64(n)*0.99)] + + return avg, p99 +} + +// sortDurations sorts a slice of durations in ascending order (insertion sort, fine for 1000 items). +func sortDurations(d []time.Duration) { + for i := 1; i < len(d); i++ { + key := d[i] + j := i - 1 + for j >= 0 && d[j] > key { + d[j+1] = d[j] + j-- + } + d[j+1] = key + } +} From 80e7b299f15896dd2962bda9955e3140a3dc001f Mon Sep 17 00:00:00 2001 From: Ayush Rawat Date: Mon, 23 Mar 2026 04:08:13 +0530 Subject: [PATCH 6/8] Refactor Policy Enforcer to Policy Checker - Renamed the `PolicyEnforcer` interface and related implementations to `PolicyChecker` for clarity and consistency. - Updated configuration keys in YAML files to reflect the new `checkPolicy` terminology. - Adjusted related code, tests, and documentation to support the new naming convention and ensure compatibility. - Enhanced comments and examples for the `checkPolicy` configuration to improve usability. --- cmd/adapter/main_test.go | 4 +- config/local-beckn-one-bap.yaml | 36 +- config/local-beckn-one-bpp.yaml | 36 +- config/local-simple.yaml | 22 +- config/onix/adapter.yaml | 58 +- core/module/handler/config.go | 4 +- core/module/handler/stdHandler.go | 8 +- core/module/handler/stdHandler_test.go | 58 + core/module/handler/step.go | 20 +- core/module/module_test.go | 4 +- install/build-plugins.sh | 2 +- install/docker-compose-adapter-beckn-one.yml | 2 - install/docker-compose-adapter.yml | 1 - pkg/plugin/definition/policyChecker.go | 17 + pkg/plugin/definition/policyEnforcer.go | 17 - .../implementation/opapolicychecker/README.md | 194 +++ .../benchmark_test.go | 32 +- .../cmd/plugin.go | 14 +- .../opapolicychecker/enforcer.go | 281 +++++ .../opapolicychecker/enforcer_test.go | 1098 +++++++++++++++++ .../opapolicychecker/evaluator.go | 395 ++++++ .../opapolicychecker/testdata/example.rego | 52 + .../implementation/policyenforcer/README.md | 113 -- .../implementation/policyenforcer/config.go | 126 -- .../implementation/policyenforcer/enforcer.go | 106 -- .../policyenforcer/enforcer_test.go | 520 -------- .../policyenforcer/evaluator.go | 220 ---- pkg/plugin/manager.go | 10 +- policies/compliance.rego | 18 - 29 files changed, 2239 insertions(+), 1229 deletions(-) create mode 100644 pkg/plugin/definition/policyChecker.go delete mode 100644 pkg/plugin/definition/policyEnforcer.go create mode 100644 pkg/plugin/implementation/opapolicychecker/README.md rename pkg/plugin/implementation/{policyenforcer => opapolicychecker}/benchmark_test.go (95%) rename pkg/plugin/implementation/{policyenforcer => opapolicychecker}/cmd/plugin.go (50%) create mode 100644 pkg/plugin/implementation/opapolicychecker/enforcer.go create mode 100644 pkg/plugin/implementation/opapolicychecker/enforcer_test.go create mode 100644 pkg/plugin/implementation/opapolicychecker/evaluator.go create mode 100644 pkg/plugin/implementation/opapolicychecker/testdata/example.rego delete mode 100644 pkg/plugin/implementation/policyenforcer/README.md delete mode 100644 pkg/plugin/implementation/policyenforcer/config.go delete mode 100644 pkg/plugin/implementation/policyenforcer/enforcer.go delete mode 100644 pkg/plugin/implementation/policyenforcer/enforcer_test.go delete mode 100644 pkg/plugin/implementation/policyenforcer/evaluator.go delete mode 100644 policies/compliance.rego diff --git a/cmd/adapter/main_test.go b/cmd/adapter/main_test.go index 6f7864d..cd18015 100644 --- a/cmd/adapter/main_test.go +++ b/cmd/adapter/main_test.go @@ -83,8 +83,8 @@ func (m *MockPluginManager) SchemaValidator(ctx context.Context, cfg *plugin.Con return nil, nil } -// PolicyEnforcer returns a mock implementation of the PolicyEnforcer interface. -func (m *MockPluginManager) PolicyEnforcer(ctx context.Context, cfg *plugin.Config) (definition.PolicyEnforcer, error) { +// PolicyChecker returns a mock implementation of the PolicyChecker interface. +func (m *MockPluginManager) PolicyChecker(ctx context.Context, cfg *plugin.Config) (definition.PolicyChecker, error) { return nil, nil } diff --git a/config/local-beckn-one-bap.yaml b/config/local-beckn-one-bap.yaml index 1a745f5..ece9060 100644 --- a/config/local-beckn-one-bap.yaml +++ b/config/local-beckn-one-bap.yaml @@ -86,20 +86,17 @@ modules: id: router config: routingConfig: ./config/local-beckn-one-routing-BAPReceiver.yaml - policyEnforcer: - id: policyenforcer + checkPolicy: + id: opapolicychecker config: - # policyPaths: polymorphic, auto-detects each entry as URL, directory, or file - # Examples: - # policyPaths: "./policies" # local directory - # policyPaths: "https://example.com/compliance.rego" # remote URL - # policyPaths: "./policies/compliance.rego" # local file - # For multiple sources, use YAML folded scalar (>-): - # policyPaths: >- - # https://example.com/compliance.rego, - # https://example.com/safety.rego, - # ./policies - policyPaths: "./policies" + # Policy source configuration. + # type: url | file | dir | bundle + # location: path or URL to policy source + # query: OPA query path to evaluate (required) + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" middleware: - id: reqpreprocessor config: @@ -107,7 +104,7 @@ modules: role: bap steps: - validateSign - - policyEnforcer + - checkPolicy - addRoute - validateSchema @@ -166,12 +163,15 @@ modules: config: contextKeys: transaction_id,message_id role: bap - policyEnforcer: - id: policyenforcer + checkPolicy: + id: opapolicychecker config: - policyPaths: "./policies" + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" steps: - - policyEnforcer + - checkPolicy - addRoute - sign - validateSchema diff --git a/config/local-beckn-one-bpp.yaml b/config/local-beckn-one-bpp.yaml index dece530..1bf93f2 100644 --- a/config/local-beckn-one-bpp.yaml +++ b/config/local-beckn-one-bpp.yaml @@ -84,23 +84,20 @@ modules: id: router config: routingConfig: ./config/local-beckn-one-routing-BPPReceiver.yaml - policyEnforcer: - id: policyenforcer + checkPolicy: + id: opapolicychecker config: - # policyPaths: polymorphic, auto-detects each entry as URL, directory, or file - # Examples: - # policyPaths: "./policies" # local directory - # policyPaths: "https://example.com/compliance.rego" # remote URL - # policyPaths: "./policies/compliance.rego" # local file - # For multiple sources, use YAML folded scalar (>-): - # policyPaths: >- - # https://example.com/compliance.rego, - # https://example.com/safety.rego, - # ./policies - policyPaths: "./policies" + # Policy source configuration. + # type: url | file | dir | bundle + # location: path or URL to policy source + # query: OPA query path to evaluate (required) + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" steps: - validateSign - - policyEnforcer + - checkPolicy - addRoute - validateSchema @@ -159,12 +156,15 @@ modules: config: contextKeys: transaction_id,message_id role: bpp - policyEnforcer: - id: policyenforcer + checkPolicy: + id: opapolicychecker config: - policyPaths: "./policies" + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" steps: - - policyEnforcer + - checkPolicy - addRoute - sign - validateSchema diff --git a/config/local-simple.yaml b/config/local-simple.yaml index 2888a27..e37d827 100644 --- a/config/local-simple.yaml +++ b/config/local-simple.yaml @@ -66,10 +66,13 @@ modules: id: router config: routingConfig: ./config/local-simple-routing.yaml - policyEnforcer: - id: policyenforcer + checkPolicy: + id: opapolicychecker config: - policyPaths: "./policies" + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" middleware: - id: reqpreprocessor config: @@ -77,7 +80,7 @@ modules: role: bap steps: - validateSign - - policyEnforcer + - checkPolicy - addRoute - name: bapTxnCaller @@ -167,13 +170,16 @@ modules: id: router config: routingConfig: ./config/local-simple-routing-BPPReceiver.yaml - policyEnforcer: - id: policyenforcer + checkPolicy: + id: opapolicychecker config: - policyPaths: "./policies" + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" steps: - validateSign - - policyEnforcer + - checkPolicy - addRoute - name: bppTxnCaller diff --git a/config/onix/adapter.yaml b/config/onix/adapter.yaml index 93985b3..97a5b9a 100644 --- a/config/onix/adapter.yaml +++ b/config/onix/adapter.yaml @@ -48,20 +48,17 @@ modules: id: schemavalidator config: schemaDir: /mnt/gcs/configs/schemas - policyEnforcer: - id: policyenforcer + checkPolicy: + id: opapolicychecker config: - # policyPaths: polymorphic, auto-detects each entry as URL, directory, or file - # Examples: - # policyPaths: "./policies" # local directory - # policyPaths: "https://example.com/compliance.rego" # remote URL - # policyPaths: "./policies/compliance.rego" # local file - # For multiple sources, use YAML folded scalar (>-): - # policyPaths: >- - # https://example.com/compliance.rego, - # https://example.com/safety.rego, - # ./policies - policyPaths: "./policies" + # Policy source configuration. + # type: url | file | dir | bundle + # location: path or URL to policy source + # query: OPA query path to evaluate (required) + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" signValidator: id: signvalidator publisher: @@ -80,7 +77,7 @@ modules: role: bap steps: - validateSign - - policyEnforcer + - checkPolicy - addRoute - validateSchema - name: bapTxnCaller @@ -113,10 +110,13 @@ modules: id: schemavalidator config: schemaDir: /mnt/gcs/configs/schemas - policyEnforcer: - id: policyenforcer + checkPolicy: + id: opapolicychecker config: - policyPaths: "./policies" + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" signer: id: signer publisher: @@ -135,7 +135,7 @@ modules: role: bap steps: - validateSchema - - policyEnforcer + - checkPolicy - addRoute - sign - name: bppTxnReciever @@ -169,10 +169,13 @@ modules: id: schemavalidator config: schemaDir: /mnt/gcs/configs/schemas - policyEnforcer: - id: policyenforcer + checkPolicy: + id: opapolicychecker config: - policyPaths: "./policies" + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" signValidator: id: signvalidator publisher: @@ -191,7 +194,7 @@ modules: role: bpp steps: - validateSign - - policyEnforcer + - checkPolicy - addRoute - validateSchema - name: bppTxnCaller @@ -224,10 +227,13 @@ modules: id: schemavalidator config: schemaDir: /mnt/gcs/configs/schemas - policyEnforcer: - id: policyenforcer + checkPolicy: + id: opapolicychecker config: - policyPaths: "./policies" + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" signer: id: signer publisher: @@ -246,6 +252,6 @@ modules: role: bpp steps: - validateSchema - - policyEnforcer + - checkPolicy - addRoute - sign \ No newline at end of file diff --git a/core/module/handler/config.go b/core/module/handler/config.go index 64f38fe..ea170fb 100644 --- a/core/module/handler/config.go +++ b/core/module/handler/config.go @@ -19,7 +19,7 @@ type PluginManager interface { Publisher(ctx context.Context, cfg *plugin.Config) (definition.Publisher, error) Signer(ctx context.Context, cfg *plugin.Config) (definition.Signer, error) Step(ctx context.Context, cfg *plugin.Config) (definition.Step, error) - PolicyEnforcer(ctx context.Context, cfg *plugin.Config) (definition.PolicyEnforcer, error) + PolicyChecker(ctx context.Context, cfg *plugin.Config) (definition.PolicyChecker, error) Cache(ctx context.Context, cfg *plugin.Config) (definition.Cache, error) Registry(ctx context.Context, cfg *plugin.Config) (definition.RegistryLookup, error) KeyManager(ctx context.Context, cache definition.Cache, rLookup definition.RegistryLookup, cfg *plugin.Config) (definition.KeyManager, error) @@ -38,7 +38,7 @@ const ( // PluginCfg holds the configuration for various plugins. type PluginCfg struct { SchemaValidator *plugin.Config `yaml:"schemaValidator,omitempty"` - PolicyEnforcer *plugin.Config `yaml:"policyEnforcer,omitempty"` + PolicyChecker *plugin.Config `yaml:"checkPolicy,omitempty"` SignValidator *plugin.Config `yaml:"signValidator,omitempty"` Publisher *plugin.Config `yaml:"publisher,omitempty"` Signer *plugin.Config `yaml:"signer,omitempty"` diff --git a/core/module/handler/stdHandler.go b/core/module/handler/stdHandler.go index 6b3e1c3..a568012 100644 --- a/core/module/handler/stdHandler.go +++ b/core/module/handler/stdHandler.go @@ -35,7 +35,7 @@ type stdHandler struct { registry definition.RegistryLookup km definition.KeyManager schemaValidator definition.SchemaValidator - policyEnforcer definition.PolicyEnforcer + policyChecker definition.PolicyChecker router definition.Router publisher definition.Publisher transportWrapper definition.TransportWrapper @@ -319,7 +319,7 @@ func (h *stdHandler) initPlugins(ctx context.Context, mgr PluginManager, cfg *Pl if h.transportWrapper, err = loadPlugin(ctx, "TransportWrapper", cfg.TransportWrapper, mgr.TransportWrapper); err != nil { return err } - if h.policyEnforcer, err = loadPlugin(ctx, "PolicyEnforcer", cfg.PolicyEnforcer, mgr.PolicyEnforcer); err != nil { + if h.policyChecker, err = loadPlugin(ctx, "PolicyChecker", cfg.PolicyChecker, mgr.PolicyChecker); err != nil { return err } @@ -354,8 +354,8 @@ func (h *stdHandler) initSteps(ctx context.Context, mgr PluginManager, cfg *Conf s, err = newValidateSchemaStep(h.schemaValidator) case "addRoute": s, err = newAddRouteStep(h.router) - case "policyEnforcer": - s, err = newEnforcePolicyStep(h.policyEnforcer) + case "checkPolicy": + s, err = newCheckPolicyStep(h.policyChecker) default: if customStep, exists := steps[step]; exists { s = customStep diff --git a/core/module/handler/stdHandler_test.go b/core/module/handler/stdHandler_test.go index b7215ec..34f990b 100644 --- a/core/module/handler/stdHandler_test.go +++ b/core/module/handler/stdHandler_test.go @@ -1,11 +1,69 @@ package handler import ( + "context" "net/http" + "strings" "testing" "time" + + "github.com/beckn-one/beckn-onix/pkg/plugin" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" ) +// noopPluginManager satisfies PluginManager with nil plugins (unused loaders are never invoked when config is omitted). +type noopPluginManager struct{} + +func (noopPluginManager) Middleware(context.Context, *plugin.Config) (func(http.Handler) http.Handler, error) { + return nil, nil +} +func (noopPluginManager) SignValidator(context.Context, *plugin.Config) (definition.SignValidator, error) { + return nil, nil +} +func (noopPluginManager) Validator(context.Context, *plugin.Config) (definition.SchemaValidator, error) { + return nil, nil +} +func (noopPluginManager) Router(context.Context, *plugin.Config) (definition.Router, error) { return nil, nil } +func (noopPluginManager) Publisher(context.Context, *plugin.Config) (definition.Publisher, error) { + return nil, nil +} +func (noopPluginManager) Signer(context.Context, *plugin.Config) (definition.Signer, error) { return nil, nil } +func (noopPluginManager) Step(context.Context, *plugin.Config) (definition.Step, error) { return nil, nil } +func (noopPluginManager) PolicyChecker(context.Context, *plugin.Config) (definition.PolicyChecker, error) { + return nil, nil +} +func (noopPluginManager) Cache(context.Context, *plugin.Config) (definition.Cache, error) { return nil, nil } +func (noopPluginManager) Registry(context.Context, *plugin.Config) (definition.RegistryLookup, error) { + return nil, nil +} +func (noopPluginManager) KeyManager(context.Context, definition.Cache, definition.RegistryLookup, *plugin.Config) (definition.KeyManager, error) { + return nil, nil +} +func (noopPluginManager) TransportWrapper(context.Context, *plugin.Config) (definition.TransportWrapper, error) { + return nil, nil +} +func (noopPluginManager) SchemaValidator(context.Context, *plugin.Config) (definition.SchemaValidator, error) { + return nil, nil +} + +func TestNewStdHandler_CheckPolicyStepWithoutPluginFails(t *testing.T) { + ctx := context.Background() + cfg := &Config{ + Plugins: PluginCfg{}, + Steps: []string{"checkPolicy"}, + } + _, err := NewStdHandler(ctx, noopPluginManager{}, cfg, "testModule") + if err == nil { + t.Fatal("expected error when steps list checkPolicy but checkPolicy plugin is omitted") + } + if !strings.Contains(err.Error(), "failed to initialize steps") { + t.Fatalf("expected steps init failure, got: %v", err) + } + if !strings.Contains(err.Error(), "PolicyChecker plugin not configured") { + t.Fatalf("expected explicit PolicyChecker config error, got: %v", err) + } +} + func TestNewHTTPClient(t *testing.T) { tests := []struct { name string diff --git a/core/module/handler/step.go b/core/module/handler/step.go index 7817709..97dc7b9 100644 --- a/core/module/handler/step.go +++ b/core/module/handler/step.go @@ -316,10 +316,18 @@ func extractSchemaVersion(body []byte) string { return "unknown" } -// newEnforcePolicyStep creates and returns the enforcePolicy step after validation. -func newEnforcePolicyStep(policyEnforcer definition.PolicyEnforcer) (definition.Step, error) { - if policyEnforcer == nil { - return nil, fmt.Errorf("invalid config: PolicyEnforcer plugin not configured") - } - return policyEnforcer, nil +// checkPolicyStep adapts PolicyChecker into the Step interface. +type checkPolicyStep struct { + checker definition.PolicyChecker +} + +func newCheckPolicyStep(policyChecker definition.PolicyChecker) (definition.Step, error) { + if policyChecker == nil { + return nil, fmt.Errorf("invalid config: PolicyChecker plugin not configured") + } + return &checkPolicyStep{checker: policyChecker}, nil +} + +func (s *checkPolicyStep) Run(ctx *model.StepContext) error { + return s.checker.CheckPolicy(ctx) } diff --git a/core/module/module_test.go b/core/module/module_test.go index 302a2de..3f26c4c 100644 --- a/core/module/module_test.go +++ b/core/module/module_test.go @@ -79,8 +79,8 @@ func (m *mockPluginManager) SchemaValidator(ctx context.Context, cfg *plugin.Con return nil, nil } -// PolicyEnforcer returns a mock policy enforcer implementation. -func (m *mockPluginManager) PolicyEnforcer(ctx context.Context, cfg *plugin.Config) (definition.PolicyEnforcer, error) { +// PolicyChecker returns a mock policy checker implementation. +func (m *mockPluginManager) PolicyChecker(ctx context.Context, cfg *plugin.Config) (definition.PolicyChecker, error) { return nil, nil } diff --git a/install/build-plugins.sh b/install/build-plugins.sh index 0cdfcaa..4ace42b 100755 --- a/install/build-plugins.sh +++ b/install/build-plugins.sh @@ -23,7 +23,7 @@ plugins=( "schemav2validator" "signer" "signvalidator" - "policyenforcer" + "opapolicychecker" ) for plugin in "${plugins[@]}"; do diff --git a/install/docker-compose-adapter-beckn-one.yml b/install/docker-compose-adapter-beckn-one.yml index 435d4e0..f5785a1 100644 --- a/install/docker-compose-adapter-beckn-one.yml +++ b/install/docker-compose-adapter-beckn-one.yml @@ -36,7 +36,6 @@ services: volumes: - ../config:/app/config - ../schemas:/app/schemas - - ../policies:/app/policies command: ["./server", "--config=/app/config/local-beckn-one-bap.yaml"] onix-bpp: @@ -58,7 +57,6 @@ services: volumes: - ../config:/app/config - ../schemas:/app/schemas - - ../policies:/app/policies command: ["./server", "--config=/app/config/local-beckn-one-bpp.yaml"] sandbox-bap: diff --git a/install/docker-compose-adapter.yml b/install/docker-compose-adapter.yml index 90bcd1b..b2839dd 100644 --- a/install/docker-compose-adapter.yml +++ b/install/docker-compose-adapter.yml @@ -36,7 +36,6 @@ services: volumes: - ../config:/app/config - ../schemas:/app/schemas - - ../policies:/app/policies command: ["./server", "--config=/app/config/local-simple.yaml"] # Vault - Key Management Service diff --git a/pkg/plugin/definition/policyChecker.go b/pkg/plugin/definition/policyChecker.go new file mode 100644 index 0000000..7fadf1f --- /dev/null +++ b/pkg/plugin/definition/policyChecker.go @@ -0,0 +1,17 @@ +package definition + +import ( + "context" + + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// PolicyChecker interface for policy checking on incoming messages. +type PolicyChecker interface { + CheckPolicy(ctx *model.StepContext) error +} + +// PolicyCheckerProvider interface for creating policy checkers. +type PolicyCheckerProvider interface { + New(ctx context.Context, config map[string]string) (PolicyChecker, func(), error) +} diff --git a/pkg/plugin/definition/policyEnforcer.go b/pkg/plugin/definition/policyEnforcer.go deleted file mode 100644 index 659bda0..0000000 --- a/pkg/plugin/definition/policyEnforcer.go +++ /dev/null @@ -1,17 +0,0 @@ -package definition - -import ( - "context" - - "github.com/beckn-one/beckn-onix/pkg/model" -) - -// PolicyEnforcer interface for policy enforcement on incoming messages. -type PolicyEnforcer interface { - Run(ctx *model.StepContext) error -} - -// PolicyEnforcerProvider interface for creating policy enforcers. -type PolicyEnforcerProvider interface { - New(ctx context.Context, config map[string]string) (PolicyEnforcer, func(), error) -} diff --git a/pkg/plugin/implementation/opapolicychecker/README.md b/pkg/plugin/implementation/opapolicychecker/README.md new file mode 100644 index 0000000..159fdf4 --- /dev/null +++ b/pkg/plugin/implementation/opapolicychecker/README.md @@ -0,0 +1,194 @@ +# OPA Policy Checker Plugin + +Validates incoming Beckn messages against network-defined business rules using [Open Policy Agent (OPA)](https://www.openpolicyagent.org/) and the Rego policy language. Non-compliant messages are rejected with a `BadRequest` error code. + +## Features + +- Evaluates business rules defined in Rego policies +- Supports multiple policy sources: remote URL, local file, directory, or OPA bundle (`.tar.gz`) +- Structured result format: `{"valid": bool, "violations": []string}` +- Fail-closed on empty/undefined query results — misconfigured policies are treated as violations +- Runtime config forwarding: adapter config values are accessible in Rego as `data.config.` +- Action-based enforcement: apply policies only to specific beckn actions (e.g., `confirm`, `search`) + +## Configuration + +```yaml +checkPolicy: + id: opapolicychecker + config: + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" + actions: "confirm,search" +steps: + - checkPolicy + - addRoute +``` + +### Configuration Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `type` | string | Yes | - | Policy source type: `url`, `file`, `dir`, or `bundle` | +| `location` | string | Yes | - | Path or URL to the policy source (`.tar.gz` for bundles) | +| `query` | string | Yes | - | Rego query path to evaluate (e.g., `data.policy.result`) | +| `actions` | string | No | *(all)* | Comma-separated beckn actions to enforce | +| `enabled` | string | No | `"true"` | Enable or disable the plugin | +| `debugLogging` | string | No | `"false"` | Enable verbose OPA evaluation logging | +| `refreshIntervalSeconds` | string | No | - | Reload policies every N seconds (0 or omit = disabled) | +| *any other key* | string | No | - | Forwarded to Rego as `data.config.` | + + + +## Policy Hot-Reload + +When `refreshIntervalSeconds` is set, a background goroutine periodically re-fetches and recompiles the policy source without restarting the adapter: + +- **Atomic swap**: the old evaluator stays fully active until the new one is compiled — no gap in enforcement +- **Non-fatal errors**: if the reload fails (e.g., file temporarily unreachable or parse error), the error is logged and the previous policy stays active +- **Goroutine lifecycle**: the reload loop is tied to the adapter context and stops cleanly on shutdown + +```yaml +config: + type: file + location: ./policies/compliance.rego + query: "data.policy.result" + refreshIntervalSeconds: "300" # reload every 5 minutes +``` + +## How It Works + +### Initialization (Load Time) + +1. **Load Policy Source**: Fetches `.rego` files from the configured `location` — URL, file, directory, or OPA bundle +2. **Compile Policies**: Compiles all Rego modules into a single optimized `PreparedEvalQuery` +3. **Set Query**: Prepares the OPA query from the configured `query` path (e.g., `data.policy.result`) + +### Request Evaluation (Runtime) + +1. **Check Action Match**: If `actions` is configured, skip evaluation for non-matching actions +2. **Evaluate OPA Query**: Run the prepared query with the full beckn message as `input` +3. **Handle Result**: + - If the query returns no result (undefined) → **violation** (fail-closed) + - If result is `{"valid": bool, "violations": []string}` → use structured format + - If result is a `set` or `[]string` → each string is a violation + - If result is a `bool` → `false` = violation + - If result is a `string` → non-empty = violation +4. **Reject or Allow**: If violations are found, NACK the request with all violation messages + +### Supported Query Output Formats + +| Rego Output | Behavior | +|-------------|----------| +| `{"valid": bool, "violations": ["string"]}` | Structured result format (recommended) | +| `set()` / `[]string` | Each string is a violation message | +| `bool` (`true`/`false`) | `false` = denied, `true` = allowed | +| `string` | Non-empty = violation | +| Empty/undefined | **Violation** (fail-closed) — indicates misconfigured query path | + +## Example Usage + +### Local File + +```yaml +checkPolicy: + id: opapolicychecker + config: + type: file + location: ./pkg/plugin/implementation/opapolicychecker/testdata/example.rego + query: "data.policy.result" +``` + +### Remote URL + +```yaml +checkPolicy: + id: opapolicychecker + config: + type: url + location: https://policies.example.com/compliance.rego + query: "data.policy.result" +``` + +### Local Directory (multiple `.rego` files) + +```yaml +checkPolicy: + id: opapolicychecker + config: + type: dir + location: ./policies + query: "data.policy.result" +``` + +### OPA Bundle (`.tar.gz`) + +```yaml +checkPolicy: + id: opapolicychecker + config: + type: bundle + location: https://nfo.example.org/policies/bundle.tar.gz + query: "data.retail.validation.result" +``` + +## Writing Policies + +Policies are written in [Rego](https://www.openpolicyagent.org/docs/latest/policy-language/). The plugin passes the full beckn message body as `input` and any adapter config values as `data.config`: + +```rego +package policy + +import rego.v1 + +# Default result: valid with no violations. +default result := { + "valid": true, + "violations": [] +} + +# Compute the result from collected violations. +result := { + "valid": count(violations) == 0, + "violations": violations +} + +# Require provider on confirm +violations contains "confirm: missing provider" if { + input.context.action == "confirm" + not input.message.order.provider +} + +# Configurable threshold from adapter config +violations contains "delivery lead time too short" if { + input.context.action == "confirm" + lead := input.message.order.fulfillments[_].start.time.duration + to_number(lead) < to_number(data.config.minDeliveryLeadHours) +} +``` + +See [`testdata/example.rego`](./testdata/example.rego) for a full working example. + +## Relationship with Schema Validator + +`opapolicychecker` and `schemav2validator` serve different purposes: + +- **Schemav2Validator**: Validates message **structure** against OpenAPI/JSON Schema specs +- **OPA Policy Checker**: Evaluates **business rules** via OPA/Rego policies + +Configure them side-by-side in your adapter steps as needed. + +## Plugin ID vs Step Name + +- **Plugin ID** (used in `id:`): `opapolicychecker` (lowercase, implementation-specific) +- **Step name** (used in `steps:` list and YAML key): `checkPolicy` (camelCase verb) + +## Dependencies + +- `github.com/open-policy-agent/opa` — OPA Go SDK for policy evaluation and bundle loading + +## Known Limitations + +- **No bundle signature verification**: When using `type: bundle`, bundle signature verification is skipped. This is planned for a future enhancement. +- **Network-level scoping**: Policies apply to all messages handled by the adapter instance. Per-network policy mapping (by `networkId`) is tracked for follow-up. diff --git a/pkg/plugin/implementation/policyenforcer/benchmark_test.go b/pkg/plugin/implementation/opapolicychecker/benchmark_test.go similarity index 95% rename from pkg/plugin/implementation/policyenforcer/benchmark_test.go rename to pkg/plugin/implementation/opapolicychecker/benchmark_test.go index f7cb1dd..f070905 100644 --- a/pkg/plugin/implementation/policyenforcer/benchmark_test.go +++ b/pkg/plugin/implementation/opapolicychecker/benchmark_test.go @@ -5,7 +5,7 @@ // // Run human-readable report: go test -run TestBenchmarkReport -v -count=1 // Run Go benchmarks: go test -bench=. -benchmem -count=1 -package policyenforcer +package opapolicychecker import ( "context" @@ -101,12 +101,21 @@ func BenchmarkEvaluate_MostlyInactive(b *testing.B) { dir := b.TempDir() os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) if err != nil { b.Fatalf("NewEvaluator failed: %v", err) } ctx := context.Background() + + violations, err := eval.Evaluate(ctx, sampleBecknInput) + if err != nil { + b.Fatalf("correctness check failed: %v", err) + } + if len(violations) != 1 || violations[0] != "real_violation" { + b.Fatalf("expected [real_violation], got %v", violations) + } + b.ResetTimer() for i := 0; i < b.N; i++ { _, err := eval.Evaluate(ctx, sampleBecknInput) @@ -126,12 +135,21 @@ func BenchmarkEvaluate_AllActive(b *testing.B) { dir := b.TempDir() os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateActiveRules(n)), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) if err != nil { b.Fatalf("NewEvaluator failed: %v", err) } ctx := context.Background() + + violations, err := eval.Evaluate(ctx, sampleBecknInput) + if err != nil { + b.Fatalf("correctness check failed: %v", err) + } + if len(violations) != n { + b.Fatalf("expected %d violations, got %d", n, len(violations)) + } + b.ResetTimer() for i := 0; i < b.N; i++ { _, err := eval.Evaluate(ctx, sampleBecknInput) @@ -154,7 +172,7 @@ func BenchmarkCompilation(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + _, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) if err != nil { b.Fatalf("NewEvaluator failed: %v", err) } @@ -188,7 +206,7 @@ func TestBenchmarkReport(t *testing.T) { os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644) start := time.Now() - _, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + _, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) elapsed := time.Since(start) if err != nil { t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err) @@ -209,7 +227,7 @@ func TestBenchmarkReport(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) if err != nil { t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err) } @@ -245,7 +263,7 @@ func TestBenchmarkReport(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateActiveRules(n)), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) if err != nil { t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err) } diff --git a/pkg/plugin/implementation/policyenforcer/cmd/plugin.go b/pkg/plugin/implementation/opapolicychecker/cmd/plugin.go similarity index 50% rename from pkg/plugin/implementation/policyenforcer/cmd/plugin.go rename to pkg/plugin/implementation/opapolicychecker/cmd/plugin.go index 91d25ae..cb8919a 100644 --- a/pkg/plugin/implementation/policyenforcer/cmd/plugin.go +++ b/pkg/plugin/implementation/opapolicychecker/cmd/plugin.go @@ -1,4 +1,4 @@ -// Package main provides the plugin entry point for the Policy Enforcer plugin. +// Package main provides the plugin entry point for the OPA Policy Checker plugin. // This file is compiled as a Go plugin (.so) and loaded by beckn-onix at runtime. package main @@ -6,20 +6,20 @@ import ( "context" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/policyenforcer" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/opapolicychecker" ) -// provider implements the PolicyEnforcerProvider interface for plugin loading. +// provider implements the PolicyCheckerProvider interface for plugin loading. type provider struct{} -// New creates a new PolicyEnforcer instance. -func (p provider) New(ctx context.Context, cfg map[string]string) (definition.PolicyEnforcer, func(), error) { - enforcer, err := policyenforcer.New(cfg) +// New creates a new PolicyChecker instance. +func (p provider) New(ctx context.Context, cfg map[string]string) (definition.PolicyChecker, func(), error) { + checker, err := opapolicychecker.New(ctx, cfg) if err != nil { return nil, nil, err } - return enforcer, enforcer.Close, nil + return checker, checker.Close, nil } // Provider is the exported symbol that beckn-onix plugin manager looks up. diff --git a/pkg/plugin/implementation/opapolicychecker/enforcer.go b/pkg/plugin/implementation/opapolicychecker/enforcer.go new file mode 100644 index 0000000..0dd631c --- /dev/null +++ b/pkg/plugin/implementation/opapolicychecker/enforcer.go @@ -0,0 +1,281 @@ +package opapolicychecker + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// Config holds the configuration for the OPA Policy Checker plugin. +type Config struct { + Type string + Location string + PolicyPaths []string + Query string + Actions []string + Enabled bool + DebugLogging bool + IsBundle bool + RefreshInterval time.Duration // 0 = disabled + RuntimeConfig map[string]string +} + +var knownKeys = map[string]bool{ + "type": true, + "location": true, + "query": true, + "actions": true, + "enabled": true, + "debugLogging": true, + "refreshIntervalSeconds": true, +} + +func DefaultConfig() *Config { + return &Config{ + Enabled: true, + RuntimeConfig: make(map[string]string), + } +} + +// ParseConfig parses the plugin configuration map into a Config struct. +// Uses type + location pattern (matches schemav2validator). +func ParseConfig(cfg map[string]string) (*Config, error) { + config := DefaultConfig() + + typ, hasType := cfg["type"] + if !hasType || typ == "" { + return nil, fmt.Errorf("'type' is required (url, file, dir, or bundle)") + } + config.Type = typ + + location, hasLoc := cfg["location"] + if !hasLoc || location == "" { + return nil, fmt.Errorf("'location' is required") + } + config.Location = location + + switch typ { + case "url": + for _, u := range strings.Split(location, ",") { + u = strings.TrimSpace(u) + if u != "" { + config.PolicyPaths = append(config.PolicyPaths, u) + } + } + case "file": + config.PolicyPaths = append(config.PolicyPaths, location) + case "dir": + config.PolicyPaths = append(config.PolicyPaths, location) + case "bundle": + config.IsBundle = true + config.PolicyPaths = append(config.PolicyPaths, location) + default: + return nil, fmt.Errorf("unsupported type %q (expected: url, file, dir, or bundle)", typ) + } + + query, hasQuery := cfg["query"] + if !hasQuery || query == "" { + return nil, fmt.Errorf("'query' is required (e.g., data.policy.violations)") + } + config.Query = query + + if actions, ok := cfg["actions"]; ok && actions != "" { + actionList := strings.Split(actions, ",") + config.Actions = make([]string, 0, len(actionList)) + for _, action := range actionList { + action = strings.TrimSpace(action) + if action != "" { + config.Actions = append(config.Actions, action) + } + } + } + + if enabled, ok := cfg["enabled"]; ok { + config.Enabled = enabled == "true" || enabled == "1" + } + + if debug, ok := cfg["debugLogging"]; ok { + config.DebugLogging = debug == "true" || debug == "1" + } + + if ris, ok := cfg["refreshIntervalSeconds"]; ok && ris != "" { + secs, err := strconv.Atoi(ris) + if err != nil || secs < 0 { + return nil, fmt.Errorf("'refreshIntervalSeconds' must be a non-negative integer, got %q", ris) + } + config.RefreshInterval = time.Duration(secs) * time.Second + } + + for k, v := range cfg { + if !knownKeys[k] { + config.RuntimeConfig[k] = v + } + } + + return config, nil +} + +func (c *Config) IsActionEnabled(action string) bool { + if len(c.Actions) == 0 { + return true + } + for _, a := range c.Actions { + if a == action { + return true + } + } + return false +} + +// PolicyEnforcer evaluates beckn messages against OPA policies and NACKs non-compliant messages. +type PolicyEnforcer struct { + config *Config + evaluator *Evaluator + evaluatorMu sync.RWMutex +} + +// getEvaluator safely returns the current evaluator under a read lock. +func (e *PolicyEnforcer) getEvaluator() *Evaluator { + e.evaluatorMu.RLock() + ev := e.evaluator + e.evaluatorMu.RUnlock() + return ev +} + +// setEvaluator safely swaps the evaluator under a write lock. +func (e *PolicyEnforcer) setEvaluator(ev *Evaluator) { + e.evaluatorMu.Lock() + e.evaluator = ev + e.evaluatorMu.Unlock() +} + +func New(ctx context.Context, cfg map[string]string) (*PolicyEnforcer, error) { + config, err := ParseConfig(cfg) + if err != nil { + return nil, fmt.Errorf("opapolicychecker: config error: %w", err) + } + + evaluator, err := NewEvaluator(config.PolicyPaths, config.Query, config.RuntimeConfig, config.IsBundle) + if err != nil { + return nil, fmt.Errorf("opapolicychecker: failed to initialize OPA evaluator: %w", err) + } + + log.Infof(ctx, "OPAPolicyChecker initialized (actions=%v, query=%s, policies=%v, isBundle=%v, debugLogging=%v, refreshInterval=%s)", + config.Actions, config.Query, evaluator.ModuleNames(), config.IsBundle, config.DebugLogging, config.RefreshInterval) + + enforcer := &PolicyEnforcer{ + config: config, + evaluator: evaluator, + } + + if config.RefreshInterval > 0 { + go enforcer.refreshLoop(ctx) + } + + return enforcer, nil +} + +// refreshLoop periodically reloads and recompiles OPA policies. +// Follows the schemav2validator pattern: driven by context cancellation. +func (e *PolicyEnforcer) refreshLoop(ctx context.Context) { + ticker := time.NewTicker(e.config.RefreshInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Infof(ctx, "OPAPolicyChecker: refresh loop stopped") + return + case <-ticker.C: + e.reloadPolicies(ctx) + } + } +} + +// reloadPolicies reloads and recompiles all policies, atomically swapping the evaluator. +// Reload failures are non-fatal; the old evaluator stays active. +func (e *PolicyEnforcer) reloadPolicies(ctx context.Context) { + start := time.Now() + newEvaluator, err := NewEvaluator( + e.config.PolicyPaths, + e.config.Query, + e.config.RuntimeConfig, + e.config.IsBundle, + ) + if err != nil { + log.Errorf(ctx, err, "OPAPolicyChecker: policy reload failed (keeping previous policies): %v", err) + return + } + + e.setEvaluator(newEvaluator) + log.Infof(ctx, "OPAPolicyChecker: policies reloaded in %s (modules=%v)", time.Since(start), newEvaluator.ModuleNames()) +} + +// CheckPolicy evaluates the message body against loaded OPA policies. +// Returns a BadReqErr (causing NACK) if violations are found. +// Returns an error on evaluation failure (fail closed). +func (e *PolicyEnforcer) CheckPolicy(ctx *model.StepContext) error { + if !e.config.Enabled { + log.Debug(ctx, "OPAPolicyChecker: plugin disabled, skipping") + return nil + } + + action := extractAction(ctx.Request.URL.Path, ctx.Body) + + if !e.config.IsActionEnabled(action) { + if e.config.DebugLogging { + log.Debugf(ctx, "OPAPolicyChecker: action %q not in configured actions %v, skipping", action, e.config.Actions) + } + return nil + } + + ev := e.getEvaluator() + + if e.config.DebugLogging { + log.Debugf(ctx, "OPAPolicyChecker: evaluating policies for action %q (modules=%v)", action, ev.ModuleNames()) + } + + violations, err := ev.Evaluate(ctx, ctx.Body) + if err != nil { + log.Errorf(ctx, err, "OPAPolicyChecker: policy evaluation failed: %v", err) + return model.NewBadReqErr(fmt.Errorf("policy evaluation error: %w", err)) + } + + if len(violations) == 0 { + if e.config.DebugLogging { + log.Debugf(ctx, "OPAPolicyChecker: message compliant for action %q", action) + } + return nil + } + + msg := fmt.Sprintf("policy violation(s): %s", strings.Join(violations, "; ")) + log.Warnf(ctx, "OPAPolicyChecker: %s", msg) + return model.NewBadReqErr(fmt.Errorf("%s", msg)) +} + +func (e *PolicyEnforcer) Close() {} + +func extractAction(urlPath string, body []byte) string { + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + if len(parts) >= 3 { + return parts[len(parts)-1] + } + + var payload struct { + Context struct { + Action string `json:"action"` + } `json:"context"` + } + if err := json.Unmarshal(body, &payload); err == nil && payload.Context.Action != "" { + return payload.Context.Action + } + + return "" +} diff --git a/pkg/plugin/implementation/opapolicychecker/enforcer_test.go b/pkg/plugin/implementation/opapolicychecker/enforcer_test.go new file mode 100644 index 0000000..2aba5a2 --- /dev/null +++ b/pkg/plugin/implementation/opapolicychecker/enforcer_test.go @@ -0,0 +1,1098 @@ +package opapolicychecker + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/open-policy-agent/opa/v1/bundle" + + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// Helper: create a StepContext with the given action path and JSON body. +func makeStepCtx(action string, body string) *model.StepContext { + req, _ := http.NewRequest("POST", "/bpp/caller/"+action, nil) + return &model.StepContext{ + Context: context.Background(), + Request: req, + Body: []byte(body), + } +} + +// Helper: write a .rego file to a temp dir and return the dir path. +func writePolicyDir(t *testing.T, filename, content string) string { + t.Helper() + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644) + if err != nil { + t.Fatalf("failed to write policy file: %v", err) + } + return dir +} + +// --- Config Tests --- + +func TestParseConfig_RequiresPolicySource(t *testing.T) { + _, err := ParseConfig(map[string]string{}) + if err == nil { + t.Fatal("expected error when no policy source given") + } +} + +func TestParseConfig_Defaults(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "type": "dir", + "location": "/tmp", + "query": "data.policy.violations", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.Actions) != 0 { + t.Errorf("expected empty default actions (all enabled), got %v", cfg.Actions) + } + if !cfg.Enabled { + t.Error("expected enabled=true by default") + } +} + +func TestParseConfig_RequiresQuery(t *testing.T) { + _, err := ParseConfig(map[string]string{ + "type": "dir", + "location": "/tmp", + }) + if err == nil { + t.Fatal("expected error when no query given") + } +} + +func TestParseConfig_RuntimeConfigForwarding(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "type": "dir", + "location": "/tmp", + "query": "data.policy.violations", + "minDeliveryLeadHours": "6", + "customParam": "value", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.RuntimeConfig["minDeliveryLeadHours"] != "6" { + t.Errorf("expected minDeliveryLeadHours=6, got %q", cfg.RuntimeConfig["minDeliveryLeadHours"]) + } + if cfg.RuntimeConfig["customParam"] != "value" { + t.Errorf("expected customParam=value, got %q", cfg.RuntimeConfig["customParam"]) + } +} + +func TestParseConfig_CustomActions(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "type": "dir", + "location": "/tmp", + "query": "data.policy.violations", + "actions": "confirm, select, init", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.Actions) != 3 { + t.Fatalf("expected 3 actions, got %d: %v", len(cfg.Actions), cfg.Actions) + } + expected := []string{"confirm", "select", "init"} + for i, want := range expected { + if cfg.Actions[i] != want { + t.Errorf("action[%d] = %q, want %q", i, cfg.Actions[i], want) + } + } +} + +func TestParseConfig_PolicyPaths(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "type": "url", + "location": "https://example.com/a.rego, https://example.com/b.rego", + "query": "data.policy.violations", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.PolicyPaths) != 2 { + t.Fatalf("expected 2 paths, got %d: %v", len(cfg.PolicyPaths), cfg.PolicyPaths) + } + if cfg.PolicyPaths[0] != "https://example.com/a.rego" { + t.Errorf("path[0] = %q", cfg.PolicyPaths[0]) + } +} + +// --- Evaluator Tests (with inline policies) --- + +func TestEvaluator_NoViolations(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains msg if { + input.value < 0 + msg := "value is negative" +} +` + dir := writePolicyDir(t, "test.rego", policy) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{"value": 10}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %d: %v", len(violations), violations) + } +} + +func TestEvaluator_WithViolation(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains msg if { + input.value < 0 + msg := "value is negative" +} +` + dir := writePolicyDir(t, "test.rego", policy) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{"value": -5}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations) + } + if violations[0] != "value is negative" { + t.Errorf("unexpected violation: %q", violations[0]) + } +} + +func TestEvaluator_RuntimeConfig(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains msg if { + input.value > to_number(data.config.maxValue) + msg := "value exceeds maximum" +} +` + dir := writePolicyDir(t, "test.rego", policy) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", map[string]string{"maxValue": "100"}, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + // Under limit + violations, err := eval.Evaluate(context.Background(), []byte(`{"value": 50}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations for value=50, got %v", violations) + } + + // Over limit + violations, err = eval.Evaluate(context.Background(), []byte(`{"value": 150}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Errorf("expected 1 violation for value=150, got %v", violations) + } +} + +func TestEvaluator_SkipsTestFiles(t *testing.T) { + dir := t.TempDir() + + policy := ` +package policy +import rego.v1 +violations contains "always" if { true } +` + os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(policy), 0644) + + // Test file would cause compilation issues if loaded (different package) + testFile := ` +package policy_test +import rego.v1 +import data.policy +test_something if { count(policy.violations) > 0 } +` + os.WriteFile(filepath.Join(dir, "policy_test.rego"), []byte(testFile), 0644) + + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + if err != nil { + t.Fatalf("NewEvaluator should skip _test.rego files, but failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Errorf("expected 1 violation, got %d", len(violations)) + } +} + +func TestEvaluator_InvalidJSON(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations := set() +` + dir := writePolicyDir(t, "test.rego", policy) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + _, err = eval.Evaluate(context.Background(), []byte(`not json`)) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +// --- Evaluator URL Fetch Tests --- + +func TestEvaluator_FetchFromURL(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains msg if { + input.value < 0 + msg := "value is negative" +} +` + // Serve the policy via a local HTTP server + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(policy)) + })) + defer srv.Close() + + eval, err := NewEvaluator([]string{srv.URL + "/test_policy.rego"}, "data.policy.violations", nil, false) + if err != nil { + t.Fatalf("NewEvaluator with URL failed: %v", err) + } + + // Compliant + violations, err := eval.Evaluate(context.Background(), []byte(`{"value": 10}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %v", violations) + } + + // Non-compliant + violations, err = eval.Evaluate(context.Background(), []byte(`{"value": -1}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Errorf("expected 1 violation, got %v", violations) + } +} + +func TestEvaluator_FetchURL_NotFound(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + + _, err := NewEvaluator([]string{srv.URL + "/missing.rego"}, "data.policy.violations", nil, false) + if err == nil { + t.Fatal("expected error for 404 URL") + } +} + +func TestEvaluator_FetchURL_InvalidScheme(t *testing.T) { + _, err := NewEvaluator([]string{"ftp://example.com/policy.rego"}, "data.policy.violations", nil, false) + if err == nil { + t.Fatal("expected error for ftp:// scheme") + } +} + +func TestEvaluator_MixedLocalAndURL(t *testing.T) { + // Local policy + localPolicy := ` +package policy +import rego.v1 +violations contains "local_violation" if { input.local_bad } +` + dir := writePolicyDir(t, "local.rego", localPolicy) + + // Remote policy (different rule, same package) + remotePolicy := ` +package policy +import rego.v1 +violations contains "remote_violation" if { input.remote_bad } +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(remotePolicy)) + })) + defer srv.Close() + + eval, err := NewEvaluator([]string{dir, srv.URL + "/remote.rego"}, "data.policy.violations", nil, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + // Trigger both violations + violations, err := eval.Evaluate(context.Background(), []byte(`{"local_bad": true, "remote_bad": true}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 2 { + t.Errorf("expected 2 violations (local+remote), got %d: %v", len(violations), violations) + } +} + +// --- Evaluator with local file path in policySources --- + +func TestEvaluator_LocalFilePath(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "from_file" if { input.bad } +` + dir := t.TempDir() + policyPath := filepath.Join(dir, "local_policy.rego") + os.WriteFile(policyPath, []byte(policy), 0644) + + eval, err := NewEvaluator([]string{policyPath}, "data.policy.violations", nil, false) + if err != nil { + t.Fatalf("NewEvaluator with local path failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{"bad": true}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 || violations[0] != "from_file" { + t.Errorf("expected [from_file], got %v", violations) + } +} + +// --- Rego Modularity Tests --- +// These tests prove that rego files can reference each other, supporting +// modular policy design to avoid rule bloat. + +// TestEvaluator_CrossFileModularity verifies that multiple .rego files +// in the SAME package automatically share rules and data. +func TestEvaluator_CrossFileModularity(t *testing.T) { + dir := t.TempDir() + + // File 1: defines a helper rule + helpers := ` +package policy +import rego.v1 +is_high_value if { input.message.order.value > 10000 } +` + os.WriteFile(filepath.Join(dir, "helpers.rego"), []byte(helpers), 0644) + + // File 2: uses the helper from file 1 (same package, auto-merged) + rules := ` +package policy +import rego.v1 +violations contains "order too large" if { is_high_value } +` + os.WriteFile(filepath.Join(dir, "rules.rego"), []byte(rules), 0644) + + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + // High value order — should trigger + violations, err := eval.Evaluate(context.Background(), []byte(`{"message":{"order":{"value":15000}}}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 || violations[0] != "order too large" { + t.Errorf("expected [order too large], got %v", violations) + } + + // Low value order — should not trigger + violations, err = eval.Evaluate(context.Background(), []byte(`{"message":{"order":{"value":500}}}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %v", violations) + } +} + +// TestEvaluator_CrossPackageImport verifies that rego files in DIFFERENT +// packages can import each other using `import data.`. +func TestEvaluator_CrossPackageImport(t *testing.T) { + dir := t.TempDir() + + // File 1: utility package with reusable helpers + utils := ` +package utils +import rego.v1 +is_confirm if { input.context.action == "confirm" } +is_high_value if { input.message.order.value > 10000 } +` + os.WriteFile(filepath.Join(dir, "utils.rego"), []byte(utils), 0644) + + // File 2: policy package imports from utils package + rules := ` +package policy +import rego.v1 +import data.utils +violations contains "high value confirm blocked" if { + utils.is_confirm + utils.is_high_value +} +` + os.WriteFile(filepath.Join(dir, "rules.rego"), []byte(rules), 0644) + + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + // confirm + high value — should fire + violations, err := eval.Evaluate(context.Background(), []byte(`{ + "context": {"action": "confirm"}, + "message": {"order": {"value": 50000}} + }`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Errorf("expected 1 violation, got %v", violations) + } + + // search action — should NOT fire (action filter in rego) + violations, err = eval.Evaluate(context.Background(), []byte(`{ + "context": {"action": "search"}, + "message": {"order": {"value": 50000}} + }`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations for search action, got %v", violations) + } +} + +// TestEvaluator_MultiFileOrganization demonstrates a realistic modular layout +// where policies are organized by concern (compliance, safety, rate-limiting) +// across separate files that all work together. +func TestEvaluator_MultiFileOrganization(t *testing.T) { + dir := t.TempDir() + + // Shared helpers + helpers := ` +package helpers +import rego.v1 +action_is(a) if { input.context.action == a } +value_exceeds(limit) if { input.message.order.value > limit } +` + os.WriteFile(filepath.Join(dir, "helpers.rego"), []byte(helpers), 0644) + + // compliance.rego — compliance rules + compliance := ` +package policy +import rego.v1 +import data.helpers +violations contains "compliance: missing provider" if { + helpers.action_is("confirm") + not input.message.order.provider +} +` + os.WriteFile(filepath.Join(dir, "compliance.rego"), []byte(compliance), 0644) + + // safety.rego — safety rules + safety := ` +package policy +import rego.v1 +import data.helpers +violations contains "safety: order value too high" if { + helpers.action_is("confirm") + helpers.value_exceeds(100000) +} +` + os.WriteFile(filepath.Join(dir, "safety.rego"), []byte(safety), 0644) + + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + // Input that triggers BOTH violations + violations, err := eval.Evaluate(context.Background(), []byte(`{ + "context": {"action": "confirm"}, + "message": {"order": {"value": 999999}} + }`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 2 { + t.Errorf("expected 2 violations (compliance+safety), got %d: %v", len(violations), violations) + } +} + +// --- Enforcer Integration Tests --- + +func TestEnforcer_Compliant(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "blocked" if { input.context.action == "confirm"; input.block } +` + dir := writePolicyDir(t, "test.rego", policy) + + enforcer, err := New(context.Background(), map[string]string{ + "type": "dir", + "location": dir, + "query": "data.policy.violations", + "actions": "confirm", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}, "block": false}`) + err = enforcer.CheckPolicy(ctx) + if err != nil { + t.Errorf("expected nil error for compliant message, got: %v", err) + } +} + +func TestEnforcer_NonCompliant(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "blocked" if { input.context.action == "confirm" } +` + dir := writePolicyDir(t, "test.rego", policy) + + enforcer, err := New(context.Background(), map[string]string{ + "type": "dir", + "location": dir, + "query": "data.policy.violations", + "actions": "confirm", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}}`) + err = enforcer.CheckPolicy(ctx) + if err == nil { + t.Fatal("expected error for non-compliant message, got nil") + } + + // Should be a BadReqErr + if _, ok := err.(*model.BadReqErr); !ok { + t.Errorf("expected *model.BadReqErr, got %T: %v", err, err) + } +} + +func TestEnforcer_SkipsNonMatchingAction(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "blocked" if { true } +` + dir := writePolicyDir(t, "test.rego", policy) + + enforcer, err := New(context.Background(), map[string]string{ + "type": "dir", + "location": dir, + "query": "data.policy.violations", + "actions": "confirm", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + // Non-compliant body, but action is "search" — not in configured actions + ctx := makeStepCtx("search", `{"context": {"action": "search"}}`) + err = enforcer.CheckPolicy(ctx) + if err != nil { + t.Errorf("expected nil for non-matching action, got: %v", err) + } +} + +func TestEnforcer_DisabledPlugin(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "blocked" if { true } +` + dir := writePolicyDir(t, "test.rego", policy) + + enforcer, err := New(context.Background(), map[string]string{ + "type": "dir", + "location": dir, + "query": "data.policy.violations", + "enabled": "false", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}}`) + err = enforcer.CheckPolicy(ctx) + if err != nil { + t.Errorf("expected nil for disabled plugin, got: %v", err) + } +} + +// --- Enforcer with URL-sourced policy --- + +func TestEnforcer_PolicyFromURL(t *testing.T) { + policy := ` +package policy +import rego.v1 +violations contains "blocked" if { input.context.action == "confirm" } +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(policy)) + })) + defer srv.Close() + + enforcer, err := New(context.Background(), map[string]string{ + "type": "url", + "location": srv.URL + "/block_confirm.rego", + "query": "data.policy.violations", + "actions": "confirm", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}}`) + err = enforcer.CheckPolicy(ctx) + if err == nil { + t.Fatal("expected error from URL-sourced policy, got nil") + } + if _, ok := err.(*model.BadReqErr); !ok { + t.Errorf("expected *model.BadReqErr, got %T", err) + } +} + +// --- extractAction Tests --- + +func TestExtractAction_FromURL(t *testing.T) { + action := extractAction("/bpp/caller/confirm", nil) + if action != "confirm" { + t.Errorf("expected 'confirm', got %q", action) + } +} + +func TestExtractAction_FromBody(t *testing.T) { + body := []byte(`{"context": {"action": "select"}}`) + action := extractAction("/x", body) + if action != "select" { + t.Errorf("expected 'select', got %q", action) + } +} + +// --- Config Tests: Bundle Type --- + +func TestParseConfig_BundleType(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "type": "bundle", + "location": "https://example.com/bundle.tar.gz", + "query": "data.retail.validation.result", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !cfg.IsBundle { + t.Error("expected IsBundle=true for type=bundle") + } + if len(cfg.PolicyPaths) != 1 || cfg.PolicyPaths[0] != "https://example.com/bundle.tar.gz" { + t.Errorf("expected 1 policy path, got %v", cfg.PolicyPaths) + } + if cfg.Query != "data.retail.validation.result" { + t.Errorf("expected query 'data.retail.validation.result', got %q", cfg.Query) + } +} + +// --- Structured Result Format Tests --- + +func TestEvaluator_StructuredResult_Valid(t *testing.T) { + // Policy returns {"valid": true, "violations": []} — no violations expected + policy := ` +package retail.policy + +import rego.v1 + +default result := { + "valid": true, + "violations": [] +} +` + dir := writePolicyDir(t, "policy.rego", policy) + eval, err := NewEvaluator([]string{dir}, "data.retail.policy.result", nil, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{"message": {"order": {"items": []}}}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations for valid result, got %v", violations) + } +} + +func TestEvaluator_StructuredResult_WithViolations(t *testing.T) { + // Policy returns {"valid": false, "violations": ["msg1", "msg2"]} when items have count <= 0 + policy := ` +package retail.policy + +import rego.v1 + +default result := { + "valid": true, + "violations": [] +} + +result := { + "valid": count(violations) == 0, + "violations": violations +} + +violations contains msg if { + some item in input.message.order.items + item.quantity.count <= 0 + msg := sprintf("item %s: quantity must be > 0", [item.id]) +} +` + dir := writePolicyDir(t, "policy.rego", policy) + eval, err := NewEvaluator([]string{dir}, "data.retail.policy.result", nil, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + // Non-compliant input + body := `{ + "message": { + "order": { + "items": [ + {"id": "item1", "quantity": {"count": 0}}, + {"id": "item2", "quantity": {"count": 5}} + ] + } + } + }` + violations, err := eval.Evaluate(context.Background(), []byte(body)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations) + } + if violations[0] != "item item1: quantity must be > 0" { + t.Errorf("unexpected violation: %q", violations[0]) + } + + // Compliant input + body = `{ + "message": { + "order": { + "items": [ + {"id": "item1", "quantity": {"count": 3}} + ] + } + } + }` + violations, err = eval.Evaluate(context.Background(), []byte(body)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations for compliant input, got %v", violations) + } +} + +func TestEvaluator_StructuredResult_FalseNoViolations(t *testing.T) { + // Edge case: valid=false but violations is empty — should report generic denial + policy := ` +package policy + +import rego.v1 + +result := { + "valid": false, + "violations": [] +} +` + dir := writePolicyDir(t, "policy.rego", policy) + eval, err := NewEvaluator([]string{dir}, "data.policy.result", nil, false) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 || violations[0] != "policy denied the request" { + t.Errorf("expected ['policy denied the request'], got %v", violations) + } +} + +// --- Bundle Tests --- + +// buildTestBundle creates an OPA bundle .tar.gz in memory from the given modules. +func buildTestBundle(t *testing.T, modules map[string]string) []byte { + t.Helper() + b := bundle.Bundle{ + Modules: make([]bundle.ModuleFile, 0, len(modules)), + Data: make(map[string]interface{}), + } + for path, content := range modules { + b.Modules = append(b.Modules, bundle.ModuleFile{ + URL: path, + Path: path, + Raw: []byte(content), + Parsed: nil, + }) + } + + var buf bytes.Buffer + if err := bundle.Write(&buf, b); err != nil { + t.Fatalf("failed to write test bundle: %v", err) + } + return buf.Bytes() +} + +func TestEvaluator_BundleFromURL(t *testing.T) { + policy := ` +package retail.validation + +import rego.v1 + +default result := { + "valid": true, + "violations": [] +} + +result := { + "valid": count(violations) == 0, + "violations": violations +} + +violations contains msg if { + some item in input.message.order.items + item.quantity.count <= 0 + msg := sprintf("item %s: quantity must be > 0", [item.id]) +} +` + bundleData := buildTestBundle(t, map[string]string{ + "retail/validation.rego": policy, + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/gzip") + w.Write(bundleData) + })) + defer srv.Close() + + eval, err := NewEvaluator([]string{srv.URL + "/bundle.tar.gz"}, "data.retail.validation.result", nil, true) + if err != nil { + t.Fatalf("NewEvaluator with bundle failed: %v", err) + } + + // Non-compliant + body := `{"message":{"order":{"items":[{"id":"x","quantity":{"count":0}}]}}}` + violations, err := eval.Evaluate(context.Background(), []byte(body)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations) + } + + // Compliant + body = `{"message":{"order":{"items":[{"id":"x","quantity":{"count":5}}]}}}` + violations, err = eval.Evaluate(context.Background(), []byte(body)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %v", violations) + } +} + +func TestEnforcer_BundlePolicy(t *testing.T) { + policy := ` +package retail.policy + +import rego.v1 + +default result := { + "valid": true, + "violations": [] +} + +result := { + "valid": count(violations) == 0, + "violations": violations +} + +violations contains "blocked" if { + input.context.action == "confirm" + not input.message.order.provider +} +` + bundleData := buildTestBundle(t, map[string]string{ + "retail/policy.rego": policy, + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/gzip") + w.Write(bundleData) + })) + defer srv.Close() + + enforcer, err := New(context.Background(), map[string]string{ + "type": "bundle", + "location": srv.URL + "/policy-bundle.tar.gz", + "query": "data.retail.policy.result", + "actions": "confirm", + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + // Non-compliant: confirm without provider + ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}, "message": {"order": {}}}`) + err = enforcer.CheckPolicy(ctx) + if err == nil { + t.Fatal("expected error for non-compliant message, got nil") + } + if _, ok := err.(*model.BadReqErr); !ok { + t.Errorf("expected *model.BadReqErr, got %T: %v", err, err) + } + + // Compliant: confirm with provider + ctx = makeStepCtx("confirm", `{"context": {"action": "confirm"}, "message": {"order": {"provider": {"id": "p1"}}}}`) + err = enforcer.CheckPolicy(ctx) + if err != nil { + t.Errorf("expected nil error for compliant message, got: %v", err) + } +} + +func TestParseConfig_RefreshInterval(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "type": "dir", + "location": "/tmp", + "query": "data.policy.violations", + "refreshIntervalSeconds": "300", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.RefreshInterval != 300*time.Second { + t.Errorf("expected 300s refresh interval, got %v", cfg.RefreshInterval) + } +} + +func TestParseConfig_RefreshInterval_Zero(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "type": "dir", + "location": "/tmp", + "query": "data.policy.violations", + // no refreshIntervalSeconds → disabled + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.RefreshInterval != 0 { + t.Errorf("expected refresh disabled (0), got %v", cfg.RefreshInterval) + } +} + +func TestParseConfig_RefreshInterval_Invalid(t *testing.T) { + _, err := ParseConfig(map[string]string{ + "type": "dir", + "location": "/tmp", + "query": "data.policy.violations", + "refreshIntervalSeconds": "not-a-number", + }) + if err == nil { + t.Fatal("expected error for invalid refreshIntervalSeconds") + } +} + +// TestEnforcer_HotReload verifies that the hot-reload goroutine picks up changes +// to a local policy file within the configured refresh interval. +func TestEnforcer_HotReload(t *testing.T) { + dir := t.TempDir() + policyPath := filepath.Join(dir, "policy.rego") + + // Initial policy: always blocks confirm + blockPolicy := `package policy +import rego.v1 +default result := {"valid": false, "violations": ["blocked by initial policy"]} +result := {"valid": false, "violations": ["blocked by initial policy"]} +` + if err := os.WriteFile(policyPath, []byte(blockPolicy), 0644); err != nil { + t.Fatalf("failed to write initial policy: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + enforcer, err := New(ctx, map[string]string{ + "type": "dir", + "location": dir, + "query": "data.policy.result", + "refreshIntervalSeconds": "1", // 1s refresh for test speed + }) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + // Confirm is blocked with initial policy + stepCtx := makeStepCtx("confirm", `{"context":{"action":"confirm"}}`) + if err := enforcer.CheckPolicy(stepCtx); err == nil { + t.Fatal("expected block from initial policy, got nil") + } + + // Swap policy on disk to allow everything + allowPolicy := `package policy +import rego.v1 +default result := {"valid": true, "violations": []} +` + if err := os.WriteFile(policyPath, []byte(allowPolicy), 0644); err != nil { + t.Fatalf("failed to write updated policy: %v", err) + } + + // Wait up to 5s for the reload to fire and swap the evaluator + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if err := enforcer.CheckPolicy(stepCtx); err == nil { + // Reload took effect + return + } + time.Sleep(200 * time.Millisecond) + } + + t.Fatal("hot-reload did not take effect within 5 seconds") +} + diff --git a/pkg/plugin/implementation/opapolicychecker/evaluator.go b/pkg/plugin/implementation/opapolicychecker/evaluator.go new file mode 100644 index 0000000..aff3584 --- /dev/null +++ b/pkg/plugin/implementation/opapolicychecker/evaluator.go @@ -0,0 +1,395 @@ +package opapolicychecker + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/bundle" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/storage/inmem" +) + +// Evaluator wraps the OPA engine: loads and compiles .rego files at startup, +// then evaluates messages against the compiled policy set. +type Evaluator struct { + preparedQuery rego.PreparedEvalQuery + query string + runtimeConfig map[string]string + moduleNames []string // names of loaded .rego modules + failOnUndefined bool // if true, empty/undefined results are treated as violations +} + +// ModuleNames returns the names of the loaded .rego policy modules. +func (e *Evaluator) ModuleNames() []string { + return e.moduleNames +} + +// policyFetchTimeout is the HTTP timeout for fetching remote .rego files. +const policyFetchTimeout = 30 * time.Second + +// maxPolicySize is the maximum size of a single .rego file fetched from a URL (1 MB). +const maxPolicySize = 1 << 20 + +// maxBundleSize is the maximum size of a bundle archive (10 MB). +const maxBundleSize = 10 << 20 + +// NewEvaluator creates an Evaluator by loading .rego files from local paths +// and/or URLs, then compiling them. runtimeConfig is passed to Rego as data.config. +// When isBundle is true, the first policyPath is treated as a URL to an OPA bundle (.tar.gz). +func NewEvaluator(policyPaths []string, query string, runtimeConfig map[string]string, isBundle bool) (*Evaluator, error) { + if isBundle { + return newBundleEvaluator(policyPaths, query, runtimeConfig) + } + return newRegoEvaluator(policyPaths, query, runtimeConfig) +} + +// newRegoEvaluator loads raw .rego files from local paths and/or URLs. +func newRegoEvaluator(policyPaths []string, query string, runtimeConfig map[string]string) (*Evaluator, error) { + modules := make(map[string]string) + + // Load from policyPaths (resolved locations based on config Type) + for _, source := range policyPaths { + if isURL(source) { + name, content, err := fetchPolicy(source) + if err != nil { + return nil, fmt.Errorf("failed to fetch policy from %s: %w", source, err) + } + modules[name] = content + } else if info, err := os.Stat(source); err == nil && info.IsDir() { + // Directory — load all .rego files inside + entries, err := os.ReadDir(source) + if err != nil { + return nil, fmt.Errorf("failed to read policy directory %s: %w", source, err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".rego") || strings.HasSuffix(entry.Name(), "_test.rego") { + continue + } + fpath := filepath.Join(source, entry.Name()) + data, err := os.ReadFile(fpath) + if err != nil { + return nil, fmt.Errorf("failed to read policy file %s: %w", fpath, err) + } + modules[entry.Name()] = string(data) + } + } else { + // Local file path + data, err := os.ReadFile(source) + if err != nil { + return nil, fmt.Errorf("failed to read policy file %s: %w", source, err) + } + modules[filepath.Base(source)] = string(data) + } + } + + if len(modules) == 0 { + return nil, fmt.Errorf("no .rego policy files found from any configured source") + } + + return compileAndPrepare(modules, nil, query, runtimeConfig, true) +} + +// newBundleEvaluator loads an OPA bundle (.tar.gz) from a URL and compiles it. +func newBundleEvaluator(policyPaths []string, query string, runtimeConfig map[string]string) (*Evaluator, error) { + if len(policyPaths) == 0 { + return nil, fmt.Errorf("bundle source URL is required") + } + + bundleURL := policyPaths[0] + modules, bundleData, err := loadBundle(bundleURL) + if err != nil { + return nil, fmt.Errorf("failed to load bundle from %s: %w", bundleURL, err) + } + + if len(modules) == 0 { + return nil, fmt.Errorf("no .rego policy modules found in bundle from %s", bundleURL) + } + + return compileAndPrepare(modules, bundleData, query, runtimeConfig, true) +} + +// loadBundle downloads a .tar.gz OPA bundle from a URL, parses it using OPA's +// bundle reader, and returns the modules and data from the bundle. +func loadBundle(bundleURL string) (map[string]string, map[string]interface{}, error) { + data, err := fetchBundleArchive(bundleURL) + if err != nil { + return nil, nil, err + } + + return parseBundleArchive(data) +} + +// fetchBundleArchive downloads a bundle .tar.gz from a URL. +func fetchBundleArchive(rawURL string) ([]byte, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("unsupported URL scheme %q (only http and https are supported)", parsed.Scheme) + } + + client := &http.Client{Timeout: policyFetchTimeout} + resp, err := client.Get(rawURL) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, rawURL) + } + + limited := io.LimitReader(resp.Body, int64(maxBundleSize)+1) + body, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + if len(body) > maxBundleSize { + return nil, fmt.Errorf("bundle exceeds maximum size of %d bytes", maxBundleSize) + } + + return body, nil +} + +// parseBundleArchive parses a .tar.gz OPA bundle archive and extracts +// rego modules and data. Signature verification is skipped. +func parseBundleArchive(data []byte) (map[string]string, map[string]interface{}, error) { + loader := bundle.NewTarballLoaderWithBaseURL(bytes.NewReader(data), "") + reader := bundle.NewCustomReader(loader). + WithSkipBundleVerification(true). + WithRegoVersion(ast.RegoV1) + + b, err := reader.Read() + if err != nil { + return nil, nil, fmt.Errorf("failed to read bundle: %w", err) + } + + modules := make(map[string]string, len(b.Modules)) + for _, m := range b.Modules { + modules[m.Path] = string(m.Raw) + } + + return modules, b.Data, nil +} + +// compileAndPrepare compiles rego modules and prepares the OPA query for evaluation. +func compileAndPrepare(modules map[string]string, bundleData map[string]interface{}, query string, runtimeConfig map[string]string, failOnUndefined bool) (*Evaluator, error) { + // Compile modules to catch syntax errors early + compiler, err := ast.CompileModulesWithOpt(modules, ast.CompileOpts{ParserOptions: ast.ParserOptions{RegoVersion: ast.RegoV1}}) + if err != nil { + return nil, fmt.Errorf("failed to compile rego modules: %w", err) + } + + // Build store data: merge bundle data with runtime config + store := make(map[string]interface{}) + for k, v := range bundleData { + store[k] = v + } + store["config"] = toInterfaceMap(runtimeConfig) + + pq, err := rego.New( + rego.Query(query), + rego.Compiler(compiler), + rego.Store(inmem.NewFromObject(store)), + ).PrepareForEval(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to prepare rego query %q: %w", query, err) + } + + names := make([]string, 0, len(modules)) + for name := range modules { + names = append(names, name) + } + + return &Evaluator{ + preparedQuery: pq, + query: query, + runtimeConfig: runtimeConfig, + moduleNames: names, + failOnUndefined: failOnUndefined, + }, nil +} + +// isURL checks if a source string looks like a remote URL. +func isURL(source string) bool { + return strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") +} + +// fetchPolicy downloads a .rego file from a URL and returns (filename, content, error). +func fetchPolicy(rawURL string) (string, string, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return "", "", fmt.Errorf("invalid URL: %w", err) + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", "", fmt.Errorf("unsupported URL scheme %q (only http and https are supported)", parsed.Scheme) + } + + client := &http.Client{Timeout: policyFetchTimeout} + resp, err := client.Get(rawURL) + if err != nil { + return "", "", fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, rawURL) + } + + // Read with size limit + limited := io.LimitReader(resp.Body, maxPolicySize+1) + body, err := io.ReadAll(limited) + if err != nil { + return "", "", fmt.Errorf("failed to read response body: %w", err) + } + if len(body) > maxPolicySize { + return "", "", fmt.Errorf("policy file exceeds maximum size of %d bytes", maxPolicySize) + } + + // Derive filename from URL path + name := path.Base(parsed.Path) + if name == "" || name == "." || name == "/" { + name = "policy.rego" + } + if !strings.HasSuffix(name, ".rego") { + name += ".rego" + } + + return name, string(body), nil +} + +// Evaluate runs the compiled policy against a JSON message body. +// Returns a list of violation strings (empty = compliant). +func (e *Evaluator) Evaluate(ctx context.Context, body []byte) ([]string, error) { + var input interface{} + if err := json.Unmarshal(body, &input); err != nil { + return nil, fmt.Errorf("failed to parse message body as JSON: %w", err) + } + + rs, err := e.preparedQuery.Eval(ctx, rego.EvalInput(input)) + if err != nil { + return nil, fmt.Errorf("rego evaluation failed: %w", err) + } + + // Fail-closed for bundles: if the query returned no result, the policy_query_path + // is likely misconfigured or the rule doesn't exist in the bundle. + if e.failOnUndefined && len(rs) == 0 { + return []string{fmt.Sprintf("policy query %q returned no result (undefined)", e.query)}, nil + } + + return extractViolations(rs) +} + +// extractViolations pulls violations from the OPA result set. +// Supported query output formats: +// - map with {"valid": bool, "violations": []string}: structured policy_query_path result +// - []string / set of strings: each string is a violation message +// - bool: false = denied ("policy denied the request"), true = allowed +// - string: non-empty = violation message +// - empty/undefined: allowed (no violations) +func extractViolations(rs rego.ResultSet) ([]string, error) { + if len(rs) == 0 { + return nil, nil + } + + var violations []string + for _, result := range rs { + for _, expr := range result.Expressions { + switch v := expr.Value.(type) { + case bool: + // allow/deny pattern: false = denied + if !v { + violations = append(violations, "policy denied the request") + } + case string: + // single violation string + if v != "" { + violations = append(violations, v) + } + case []interface{}: + // Result is a list (from set) + for _, item := range v { + if s, ok := item.(string); ok { + violations = append(violations, s) + } + } + case map[string]interface{}: + // Check for structured result: {"valid": bool, "violations": [...]} + if vs := extractStructuredViolations(v); vs != nil { + violations = append(violations, vs...) + } else { + // Fallback: OPA sometimes returns sets as maps with string keys + for key := range v { + violations = append(violations, key) + } + } + } + } + } + + return violations, nil +} + +// extractStructuredViolations handles the policy_query_path result format: +// {"valid": bool, "violations": []string} +// Returns the violation strings if the map matches this format, or nil if it doesn't. +func extractStructuredViolations(m map[string]interface{}) []string { + validRaw, hasValid := m["valid"] + violationsRaw, hasViolations := m["violations"] + + if !hasValid || !hasViolations { + return nil + } + + valid, ok := validRaw.(bool) + if !ok { + return nil + } + + violationsList, ok := violationsRaw.([]interface{}) + if !ok { + return nil + } + + // If valid is true and violations is empty, no violations + if valid && len(violationsList) == 0 { + return []string{} + } + + var violations []string + for _, item := range violationsList { + if s, ok := item.(string); ok { + violations = append(violations, s) + } + } + + // If valid is false but violations is empty, report a generic violation + if !valid && len(violations) == 0 { + violations = append(violations, "policy denied the request") + } + + return violations +} + +// toInterfaceMap converts map[string]string to map[string]interface{} for OPA store. +func toInterfaceMap(m map[string]string) map[string]interface{} { + result := make(map[string]interface{}, len(m)) + for k, v := range m { + result[k] = v + } + return result +} diff --git a/pkg/plugin/implementation/opapolicychecker/testdata/example.rego b/pkg/plugin/implementation/opapolicychecker/testdata/example.rego new file mode 100644 index 0000000..446fa72 --- /dev/null +++ b/pkg/plugin/implementation/opapolicychecker/testdata/example.rego @@ -0,0 +1,52 @@ +package policy + +import rego.v1 + +# Example policy: validation rules for beckn messages. +# This demonstrates the structured result format used with policy_query_path. +# +# Available inputs: +# - input: the full JSON message body (beckn request) +# - data.config: runtime config from the adapter YAML (e.g., minDeliveryLeadHours) + +# Default result: valid with no violations. +default result := { + "valid": true, + "violations": [] +} + +# Compute the result from collected violations. +result := { + "valid": count(violations) == 0, + "violations": violations +} + +# Require provider details on confirm +violations contains "confirm: missing provider in order" if { + input.context.action == "confirm" + not input.message.order.provider +} + +# Require at least one fulfillment on confirm +violations contains "confirm: order has no fulfillments" if { + input.context.action == "confirm" + not input.message.order.fulfillments +} + +# Require billing details on confirm +violations contains "confirm: missing billing info" if { + input.context.action == "confirm" + not input.message.order.billing +} + +# Require payment details on confirm +violations contains "confirm: missing payment info" if { + input.context.action == "confirm" + not input.message.order.payment +} + +# Require search intent +violations contains "search: missing intent" if { + input.context.action == "search" + not input.message.intent +} diff --git a/pkg/plugin/implementation/policyenforcer/README.md b/pkg/plugin/implementation/policyenforcer/README.md deleted file mode 100644 index cd64462..0000000 --- a/pkg/plugin/implementation/policyenforcer/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# Policy Enforcer Plugin - -OPA/Rego-based policy enforcement for beckn-onix adapters. Evaluates incoming beckn messages against configurable policies and NACKs non-compliant requests. - -## Overview - -The `policyenforcer` plugin is a **Step plugin** that: -- Loads `.rego` policy files from URLs, local directories, or local files -- Evaluates incoming messages against compiled OPA policies -- Returns a `BadReqErr` (NACK) when policy violations are detected -- Fails closed on evaluation errors (treats as NACK) -- Is strictly **opt-in** — adapters that don't reference it are unaffected - -## Configuration - -All config keys are passed via `map[string]string` in the adapter YAML config. - -| Key | Required | Default | Description | -|-----|----------|---------|-------------| -| `policyPaths` | Yes (at least one source required) | `./policies` (if dir exists) | Comma-separated list of policy sources — each entry is auto-detected as a **URL**, **directory**, or **file** | -| `query` | No | `data.policy.violations` | Rego query returning violation strings | -| `actions` | No | *(empty — all actions)* | Comma-separated beckn actions to enforce. When omitted, all actions are evaluated and the Rego policy itself decides which to gate. | -| `enabled` | No | `true` | Enable/disable the plugin | -| `debugLogging` | No | `false` | Enable verbose logging | -| *any other key* | No | — | Forwarded to Rego as `data.config.` | - -### Policy Sources - -`policyPaths` is the single configuration key for all policy sources. Each comma-separated entry is **auto-detected** as: -- **Remote URL** (`http://` or `https://`): fetched via HTTP at startup -- **Local directory**: all `.rego` files loaded (`_test.rego` excluded) -- **Local file**: loaded directly - -```yaml -# Single directory -config: - policyPaths: "./policies" - -# Single remote URL -config: - policyPaths: "https://policies.example.com/compliance.rego" - -# Mix of URLs, directories, and files -config: - policyPaths: "https://policies.example.com/compliance.rego,./policies,/local/safety.rego" -``` - -When specifying many sources, use the YAML folded scalar (`>-`) to keep the config readable: - -```yaml -config: - policyPaths: >- - https://policies.example.com/compliance.rego, - https://policies.example.com/safety.rego, - ./policies, - /local/overrides/rate-limit.rego -``` - -The `>-` folds newlines into spaces, so the value is parsed as a single comma-separated string. - -### Minimal Config - -By default, the plugin loads `.rego` files from `./policies` and uses the query `data.policy.violations`. A zero-config setup works if your policies are in the default directory: - -```yaml -policyEnforcer: - id: policyenforcer - config: {} -``` - -Or specify a custom policy location: - -```yaml -policyEnforcer: - id: policyenforcer - config: - policyPaths: "./policies/compliance.rego" -``` - -### Air-Gapped Deployments - -For environments without internet access, use local file paths or volume mounts: - -```yaml -config: - policyPaths: "/mounted-policies/compliance.rego,/mounted-policies/safety.rego" -``` - -## Example Config - -```yaml -plugins: - policyEnforcer: - id: policyenforcer - config: - policyPaths: >- - /local/policies/, - https://policies.example.com/compliance.rego - minDeliveryLeadHours: "4" - debugLogging: "true" -steps: - - policyEnforcer - - addRoute -``` - -## Relationship with Schema Validator - -`policyenforcer` and `schemavalidator`/`schemav2validator` are **separate plugins** with different responsibilities: - -- **Schema Validator**: Validates message **structure** against OpenAPI/JSON Schema specs -- **Policy Enforcer**: Evaluates **business rules** via OPA/Rego policies - -They use different plugin interfaces (`SchemaValidator` vs `Step`), different engines, and different error types. Configure them side-by-side in your adapter config as needed. diff --git a/pkg/plugin/implementation/policyenforcer/config.go b/pkg/plugin/implementation/policyenforcer/config.go deleted file mode 100644 index 10d44d8..0000000 --- a/pkg/plugin/implementation/policyenforcer/config.go +++ /dev/null @@ -1,126 +0,0 @@ -package policyenforcer - -import ( - "fmt" - "os" - "strings" -) - -// Config holds the configuration for the Policy Enforcer plugin. -type Config struct { - // PolicyPaths is a list of policy sources. Each entry is auto-detected as: - // - Remote URL (http:// or https://) → fetched via HTTP - // - Local directory → all .rego files loaded (excluding _test.rego) - // - Local file → loaded directly - // Parsed from the comma-separated "policyPaths" config key. - PolicyPaths []string - - // Query is the Rego query that returns a set of violation strings. - // Default: "data.policy.violations". - Query string - - // Actions is the list of beckn actions to enforce policies on. - // When empty or nil, all actions are considered and the Rego policy - // is responsible for deciding which actions to gate. - Actions []string - - // Enabled controls whether the plugin is active. - Enabled bool - - // DebugLogging enables verbose logging. - DebugLogging bool - - // RuntimeConfig holds arbitrary key-value pairs passed to Rego as data.config. - // Keys like minDeliveryLeadHours are forwarded here. - RuntimeConfig map[string]string -} - -// Known config keys that are handled directly (not forwarded to RuntimeConfig). -var knownKeys = map[string]bool{ - "policyPaths": true, - "query": true, - "actions": true, - "enabled": true, - "debugLogging": true, -} - -// DefaultConfig returns a Config with sensible defaults. -func DefaultConfig() *Config { - return &Config{ - Query: "data.policy.violations", - Enabled: true, - DebugLogging: false, - RuntimeConfig: make(map[string]string), - } -} - -// ParseConfig parses the plugin configuration map into a Config struct. -func ParseConfig(cfg map[string]string) (*Config, error) { - config := DefaultConfig() - - // Comma-separated policyPaths (each entry auto-detected as URL, directory, or file) - if paths, ok := cfg["policyPaths"]; ok && paths != "" { - for _, p := range strings.Split(paths, ",") { - p = strings.TrimSpace(p) - if p != "" { - config.PolicyPaths = append(config.PolicyPaths, p) - } - } - } - - if len(config.PolicyPaths) == 0 { - // Fall back to the default ./policies directory if it exists on disk. - if info, err := os.Stat("./policies"); err == nil && info.IsDir() { - config.PolicyPaths = append(config.PolicyPaths, "./policies") - } else { - return nil, fmt.Errorf("at least one policy source is required (policyPaths)") - } - } - - if query, ok := cfg["query"]; ok && query != "" { - config.Query = query - } - - if actions, ok := cfg["actions"]; ok && actions != "" { - actionList := strings.Split(actions, ",") - config.Actions = make([]string, 0, len(actionList)) - for _, action := range actionList { - action = strings.TrimSpace(action) - if action != "" { - config.Actions = append(config.Actions, action) - } - } - } - - if enabled, ok := cfg["enabled"]; ok { - config.Enabled = enabled == "true" || enabled == "1" - } - - if debug, ok := cfg["debugLogging"]; ok { - config.DebugLogging = debug == "true" || debug == "1" - } - - // Forward unknown keys to RuntimeConfig (e.g., minDeliveryLeadHours) - for k, v := range cfg { - if !knownKeys[k] { - config.RuntimeConfig[k] = v - } - } - - return config, nil -} - -// IsActionEnabled checks if the given action is in the configured actions list. -// When the actions list is empty/nil, all actions are enabled and action-gating -// is delegated entirely to the Rego policy. -func (c *Config) IsActionEnabled(action string) bool { - if len(c.Actions) == 0 { - return true - } - for _, a := range c.Actions { - if a == action { - return true - } - } - return false -} diff --git a/pkg/plugin/implementation/policyenforcer/enforcer.go b/pkg/plugin/implementation/policyenforcer/enforcer.go deleted file mode 100644 index 97c74cb..0000000 --- a/pkg/plugin/implementation/policyenforcer/enforcer.go +++ /dev/null @@ -1,106 +0,0 @@ -package policyenforcer - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/beckn-one/beckn-onix/pkg/log" - "github.com/beckn-one/beckn-onix/pkg/model" -) - -// PolicyEnforcer is a Step plugin that evaluates beckn messages against -// OPA policies and NACKs non-compliant messages. -type PolicyEnforcer struct { - config *Config - evaluator *Evaluator -} - -// New creates a new PolicyEnforcer instance. -func New(cfg map[string]string) (*PolicyEnforcer, error) { - config, err := ParseConfig(cfg) - if err != nil { - return nil, fmt.Errorf("policyenforcer: config error: %w", err) - } - - evaluator, err := NewEvaluator(config.PolicyPaths, config.Query, config.RuntimeConfig) - if err != nil { - return nil, fmt.Errorf("policyenforcer: failed to initialize OPA evaluator: %w", err) - } - - log.Infof(context.TODO(), "PolicyEnforcer initialized (actions=%v, query=%s, policies=%v, debugLogging=%v)", - config.Actions, config.Query, evaluator.ModuleNames(), config.DebugLogging) - - return &PolicyEnforcer{ - config: config, - evaluator: evaluator, - }, nil -} - -// Run implements the Step interface. It evaluates the message body against -// loaded OPA policies. Returns a BadReqErr (causing NACK) if violations are found. -// Returns an error on evaluation failure (fail closed). -func (e *PolicyEnforcer) Run(ctx *model.StepContext) error { - if !e.config.Enabled { - log.Debug(ctx, "PolicyEnforcer: plugin disabled, skipping") - return nil - } - - // Extract action from the message - action := extractAction(ctx.Request.URL.Path, ctx.Body) - - if !e.config.IsActionEnabled(action) { - if e.config.DebugLogging { - log.Debugf(ctx, "PolicyEnforcer: action %q not in configured actions %v, skipping", action, e.config.Actions) - } - return nil - } - - if e.config.DebugLogging { - log.Debugf(ctx, "PolicyEnforcer: evaluating policies for action %q (modules=%v)", action, e.evaluator.ModuleNames()) - } - - violations, err := e.evaluator.Evaluate(ctx, ctx.Body) - if err != nil { - // Fail closed: evaluation error → NACK - log.Errorf(ctx, err, "PolicyEnforcer: policy evaluation failed: %v", err) - return model.NewBadReqErr(fmt.Errorf("policy evaluation error: %w", err)) - } - - if len(violations) == 0 { - if e.config.DebugLogging { - log.Debugf(ctx, "PolicyEnforcer: message compliant for action %q", action) - } - return nil - } - - // Non-compliant: NACK with all violation messages - msg := fmt.Sprintf("policy violation(s): %s", strings.Join(violations, "; ")) - log.Warnf(ctx, "PolicyEnforcer: %s", msg) - return model.NewBadReqErr(fmt.Errorf("%s", msg)) -} - -// Close is a no-op for the policy enforcer (no resources to release). -func (e *PolicyEnforcer) Close() {} - -// extractAction gets the beckn action from the URL path or message body. -func extractAction(urlPath string, body []byte) string { - // Try URL path first: /bap/receiver/{action} or /bpp/caller/{action} - parts := strings.Split(strings.Trim(urlPath, "/"), "/") - if len(parts) >= 3 { - return parts[len(parts)-1] - } - - // Fallback: extract from body context.action - var payload struct { - Context struct { - Action string `json:"action"` - } `json:"context"` - } - if err := json.Unmarshal(body, &payload); err == nil && payload.Context.Action != "" { - return payload.Context.Action - } - - return "" -} diff --git a/pkg/plugin/implementation/policyenforcer/enforcer_test.go b/pkg/plugin/implementation/policyenforcer/enforcer_test.go deleted file mode 100644 index 15ac2a3..0000000 --- a/pkg/plugin/implementation/policyenforcer/enforcer_test.go +++ /dev/null @@ -1,520 +0,0 @@ -package policyenforcer - -import ( - "context" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/beckn-one/beckn-onix/pkg/model" -) - -// Helper: create a StepContext with the given action path and JSON body. -func makeStepCtx(action string, body string) *model.StepContext { - req, _ := http.NewRequest("POST", "/bpp/caller/"+action, nil) - return &model.StepContext{ - Context: context.Background(), - Request: req, - Body: []byte(body), - } -} - -// Helper: write a .rego file to a temp dir and return the dir path. -func writePolicyDir(t *testing.T, filename, content string) string { - t.Helper() - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644) - if err != nil { - t.Fatalf("failed to write policy file: %v", err) - } - return dir -} - -// --- Config Tests --- - -func TestParseConfig_RequiresPolicySource(t *testing.T) { - _, err := ParseConfig(map[string]string{}) - if err == nil { - t.Fatal("expected error when no policyPaths given") - } -} - -func TestParseConfig_Defaults(t *testing.T) { - cfg, err := ParseConfig(map[string]string{"policyPaths": "/tmp"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cfg.Query != "data.policy.violations" { - t.Errorf("expected default query 'data.policy.violations', got %q", cfg.Query) - } - if len(cfg.Actions) != 0 { - t.Errorf("expected empty default actions (all enabled), got %v", cfg.Actions) - } - if !cfg.Enabled { - t.Error("expected enabled=true by default") - } -} - -func TestParseConfig_RuntimeConfigForwarding(t *testing.T) { - cfg, err := ParseConfig(map[string]string{ - "policyPaths": "/tmp", - "minDeliveryLeadHours": "6", - "customParam": "value", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cfg.RuntimeConfig["minDeliveryLeadHours"] != "6" { - t.Errorf("expected minDeliveryLeadHours=6, got %q", cfg.RuntimeConfig["minDeliveryLeadHours"]) - } - if cfg.RuntimeConfig["customParam"] != "value" { - t.Errorf("expected customParam=value, got %q", cfg.RuntimeConfig["customParam"]) - } -} - -func TestParseConfig_CustomActions(t *testing.T) { - cfg, err := ParseConfig(map[string]string{ - "policyPaths": "/tmp", - "actions": "confirm, select, init", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(cfg.Actions) != 3 { - t.Fatalf("expected 3 actions, got %d: %v", len(cfg.Actions), cfg.Actions) - } - expected := []string{"confirm", "select", "init"} - for i, want := range expected { - if cfg.Actions[i] != want { - t.Errorf("action[%d] = %q, want %q", i, cfg.Actions[i], want) - } - } -} - -func TestParseConfig_PolicyPaths(t *testing.T) { - cfg, err := ParseConfig(map[string]string{ - "policyPaths": "https://example.com/a.rego, https://example.com/b.rego", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(cfg.PolicyPaths) != 2 { - t.Fatalf("expected 2 paths, got %d: %v", len(cfg.PolicyPaths), cfg.PolicyPaths) - } - if cfg.PolicyPaths[0] != "https://example.com/a.rego" { - t.Errorf("path[0] = %q", cfg.PolicyPaths[0]) - } -} - -// --- Evaluator Tests (with inline policies) --- - -func TestEvaluator_NoViolations(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations contains msg if { - input.value < 0 - msg := "value is negative" -} -` - dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) - if err != nil { - t.Fatalf("NewEvaluator failed: %v", err) - } - - violations, err := eval.Evaluate(context.Background(), []byte(`{"value": 10}`)) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if len(violations) != 0 { - t.Errorf("expected 0 violations, got %d: %v", len(violations), violations) - } -} - -func TestEvaluator_WithViolation(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations contains msg if { - input.value < 0 - msg := "value is negative" -} -` - dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) - if err != nil { - t.Fatalf("NewEvaluator failed: %v", err) - } - - violations, err := eval.Evaluate(context.Background(), []byte(`{"value": -5}`)) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if len(violations) != 1 { - t.Fatalf("expected 1 violation, got %d: %v", len(violations), violations) - } - if violations[0] != "value is negative" { - t.Errorf("unexpected violation: %q", violations[0]) - } -} - -func TestEvaluator_RuntimeConfig(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations contains msg if { - input.value > to_number(data.config.maxValue) - msg := "value exceeds maximum" -} -` - dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", map[string]string{"maxValue": "100"}) - if err != nil { - t.Fatalf("NewEvaluator failed: %v", err) - } - - // Under limit - violations, err := eval.Evaluate(context.Background(), []byte(`{"value": 50}`)) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if len(violations) != 0 { - t.Errorf("expected 0 violations for value=50, got %v", violations) - } - - // Over limit - violations, err = eval.Evaluate(context.Background(), []byte(`{"value": 150}`)) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if len(violations) != 1 { - t.Errorf("expected 1 violation for value=150, got %v", violations) - } -} - -func TestEvaluator_SkipsTestFiles(t *testing.T) { - dir := t.TempDir() - - policy := ` -package policy -import rego.v1 -violations contains "always" if { true } -` - os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(policy), 0644) - - // Test file would cause compilation issues if loaded (different package) - testFile := ` -package policy_test -import rego.v1 -import data.policy -test_something if { count(policy.violations) > 0 } -` - os.WriteFile(filepath.Join(dir, "policy_test.rego"), []byte(testFile), 0644) - - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) - if err != nil { - t.Fatalf("NewEvaluator should skip _test.rego files, but failed: %v", err) - } - - violations, err := eval.Evaluate(context.Background(), []byte(`{}`)) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if len(violations) != 1 { - t.Errorf("expected 1 violation, got %d", len(violations)) - } -} - -func TestEvaluator_InvalidJSON(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations := set() -` - dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil) - if err != nil { - t.Fatalf("NewEvaluator failed: %v", err) - } - - _, err = eval.Evaluate(context.Background(), []byte(`not json`)) - if err == nil { - t.Error("expected error for invalid JSON") - } -} - -// --- Evaluator URL Fetch Tests --- - -func TestEvaluator_FetchFromURL(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations contains msg if { - input.value < 0 - msg := "value is negative" -} -` - // Serve the policy via a local HTTP server - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte(policy)) - })) - defer srv.Close() - - eval, err := NewEvaluator([]string{srv.URL + "/test_policy.rego"}, "data.policy.violations", nil) - if err != nil { - t.Fatalf("NewEvaluator with URL failed: %v", err) - } - - // Compliant - violations, err := eval.Evaluate(context.Background(), []byte(`{"value": 10}`)) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if len(violations) != 0 { - t.Errorf("expected 0 violations, got %v", violations) - } - - // Non-compliant - violations, err = eval.Evaluate(context.Background(), []byte(`{"value": -1}`)) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if len(violations) != 1 { - t.Errorf("expected 1 violation, got %v", violations) - } -} - -func TestEvaluator_FetchURL_NotFound(t *testing.T) { - srv := httptest.NewServer(http.NotFoundHandler()) - defer srv.Close() - - _, err := NewEvaluator([]string{srv.URL + "/missing.rego"}, "data.policy.violations", nil) - if err == nil { - t.Fatal("expected error for 404 URL") - } -} - -func TestEvaluator_FetchURL_InvalidScheme(t *testing.T) { - _, err := NewEvaluator([]string{"ftp://example.com/policy.rego"}, "data.policy.violations", nil) - if err == nil { - t.Fatal("expected error for ftp:// scheme") - } -} - -func TestEvaluator_MixedLocalAndURL(t *testing.T) { - // Local policy - localPolicy := ` -package policy -import rego.v1 -violations contains "local_violation" if { input.local_bad } -` - dir := writePolicyDir(t, "local.rego", localPolicy) - - // Remote policy (different rule, same package) - remotePolicy := ` -package policy -import rego.v1 -violations contains "remote_violation" if { input.remote_bad } -` - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(remotePolicy)) - })) - defer srv.Close() - - eval, err := NewEvaluator([]string{dir, srv.URL + "/remote.rego"}, "data.policy.violations", nil) - if err != nil { - t.Fatalf("NewEvaluator failed: %v", err) - } - - // Trigger both violations - violations, err := eval.Evaluate(context.Background(), []byte(`{"local_bad": true, "remote_bad": true}`)) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if len(violations) != 2 { - t.Errorf("expected 2 violations (local+remote), got %d: %v", len(violations), violations) - } -} - -// --- Evaluator with local file path in policySources --- - -func TestEvaluator_LocalFilePath(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations contains "from_file" if { input.bad } -` - dir := t.TempDir() - policyPath := filepath.Join(dir, "local_policy.rego") - os.WriteFile(policyPath, []byte(policy), 0644) - - eval, err := NewEvaluator([]string{policyPath}, "data.policy.violations", nil) - if err != nil { - t.Fatalf("NewEvaluator with local path failed: %v", err) - } - - violations, err := eval.Evaluate(context.Background(), []byte(`{"bad": true}`)) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if len(violations) != 1 || violations[0] != "from_file" { - t.Errorf("expected [from_file], got %v", violations) - } -} - -// --- Enforcer Integration Tests --- - -func TestEnforcer_Compliant(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations contains "blocked" if { input.context.action == "confirm"; input.block } -` - dir := writePolicyDir(t, "test.rego", policy) - - enforcer, err := New(map[string]string{ - "policyPaths": dir, - "query": "data.policy.violations", - "actions": "confirm", - }) - if err != nil { - t.Fatalf("New failed: %v", err) - } - - ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}, "block": false}`) - err = enforcer.Run(ctx) - if err != nil { - t.Errorf("expected nil error for compliant message, got: %v", err) - } -} - -func TestEnforcer_NonCompliant(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations contains "blocked" if { input.context.action == "confirm" } -` - dir := writePolicyDir(t, "test.rego", policy) - - enforcer, err := New(map[string]string{ - "policyPaths": dir, - "query": "data.policy.violations", - "actions": "confirm", - }) - if err != nil { - t.Fatalf("New failed: %v", err) - } - - ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}}`) - err = enforcer.Run(ctx) - if err == nil { - t.Fatal("expected error for non-compliant message, got nil") - } - - // Should be a BadReqErr - if _, ok := err.(*model.BadReqErr); !ok { - t.Errorf("expected *model.BadReqErr, got %T: %v", err, err) - } -} - -func TestEnforcer_SkipsNonMatchingAction(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations contains "blocked" if { true } -` - dir := writePolicyDir(t, "test.rego", policy) - - enforcer, err := New(map[string]string{ - "policyPaths": dir, - "query": "data.policy.violations", - "actions": "confirm", - }) - if err != nil { - t.Fatalf("New failed: %v", err) - } - - // Non-compliant body, but action is "search" — not in configured actions - ctx := makeStepCtx("search", `{"context": {"action": "search"}}`) - err = enforcer.Run(ctx) - if err != nil { - t.Errorf("expected nil for non-matching action, got: %v", err) - } -} - -func TestEnforcer_DisabledPlugin(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations contains "blocked" if { true } -` - dir := writePolicyDir(t, "test.rego", policy) - - enforcer, err := New(map[string]string{ - "policyPaths": dir, - "query": "data.policy.violations", - "enabled": "false", - }) - if err != nil { - t.Fatalf("New failed: %v", err) - } - - ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}}`) - err = enforcer.Run(ctx) - if err != nil { - t.Errorf("expected nil for disabled plugin, got: %v", err) - } -} - -// --- Enforcer with URL-sourced policy --- - -func TestEnforcer_PolicyFromURL(t *testing.T) { - policy := ` -package policy -import rego.v1 -violations contains "blocked" if { input.context.action == "confirm" } -` - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(policy)) - })) - defer srv.Close() - - enforcer, err := New(map[string]string{ - "policyPaths": srv.URL + "/block_confirm.rego", - "query": "data.policy.violations", - "actions": "confirm", - }) - if err != nil { - t.Fatalf("New failed: %v", err) - } - - ctx := makeStepCtx("confirm", `{"context": {"action": "confirm"}}`) - err = enforcer.Run(ctx) - if err == nil { - t.Fatal("expected error from URL-sourced policy, got nil") - } - if _, ok := err.(*model.BadReqErr); !ok { - t.Errorf("expected *model.BadReqErr, got %T", err) - } -} - -// --- extractAction Tests --- - -func TestExtractAction_FromURL(t *testing.T) { - action := extractAction("/bpp/caller/confirm", nil) - if action != "confirm" { - t.Errorf("expected 'confirm', got %q", action) - } -} - -func TestExtractAction_FromBody(t *testing.T) { - body := []byte(`{"context": {"action": "select"}}`) - action := extractAction("/x", body) - if action != "select" { - t.Errorf("expected 'select', got %q", action) - } -} diff --git a/pkg/plugin/implementation/policyenforcer/evaluator.go b/pkg/plugin/implementation/policyenforcer/evaluator.go deleted file mode 100644 index fe3d84b..0000000 --- a/pkg/plugin/implementation/policyenforcer/evaluator.go +++ /dev/null @@ -1,220 +0,0 @@ -package policyenforcer - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path" - "path/filepath" - "strings" - "time" - - "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/rego" - "github.com/open-policy-agent/opa/v1/storage/inmem" -) - -// Evaluator wraps the OPA engine: loads and compiles .rego files at startup, -// then evaluates messages against the compiled policy set. -type Evaluator struct { - preparedQuery rego.PreparedEvalQuery - query string - runtimeConfig map[string]string - moduleNames []string // names of loaded .rego modules -} - -// ModuleNames returns the names of the loaded .rego policy modules. -func (e *Evaluator) ModuleNames() []string { - return e.moduleNames -} - -// policyFetchTimeout is the HTTP timeout for fetching remote .rego files. -const policyFetchTimeout = 30 * time.Second - -// maxPolicySize is the maximum size of a single .rego file fetched from a URL (1 MB). -const maxPolicySize = 1 << 20 - -// NewEvaluator creates an Evaluator by loading .rego files from local paths -// and/or URLs, then compiling them. runtimeConfig is passed to Rego as data.config. -func NewEvaluator(policyPaths []string, query string, runtimeConfig map[string]string) (*Evaluator, error) { - modules := make(map[string]string) - - // Load from policyPaths (each entry auto-detected as URL, directory, or file) - for _, source := range policyPaths { - if isURL(source) { - name, content, err := fetchPolicy(source) - if err != nil { - return nil, fmt.Errorf("failed to fetch policy from %s: %w", source, err) - } - modules[name] = content - } else if info, err := os.Stat(source); err == nil && info.IsDir() { - // Directory — load all .rego files inside - entries, err := os.ReadDir(source) - if err != nil { - return nil, fmt.Errorf("failed to read policy directory %s: %w", source, err) - } - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".rego") || strings.HasSuffix(entry.Name(), "_test.rego") { - continue - } - fpath := filepath.Join(source, entry.Name()) - data, err := os.ReadFile(fpath) - if err != nil { - return nil, fmt.Errorf("failed to read policy file %s: %w", fpath, err) - } - modules[entry.Name()] = string(data) - } - } else { - // Local file path - data, err := os.ReadFile(source) - if err != nil { - return nil, fmt.Errorf("failed to read policy file %s: %w", source, err) - } - modules[filepath.Base(source)] = string(data) - } - } - - if len(modules) == 0 { - return nil, fmt.Errorf("no .rego policy files found from any configured source") - } - - // Compile modules to catch syntax errors early - compiler, err := ast.CompileModulesWithOpt(modules, ast.CompileOpts{ParserOptions: ast.ParserOptions{RegoVersion: ast.RegoV1}}) - if err != nil { - return nil, fmt.Errorf("failed to compile rego modules: %w", err) - } - - // Build data.config from runtime config - store := map[string]interface{}{ - "config": toInterfaceMap(runtimeConfig), - } - - pq, err := rego.New( - rego.Query(query), - rego.Compiler(compiler), - rego.Store(inmem.NewFromObject(store)), - ).PrepareForEval(context.Background()) - if err != nil { - return nil, fmt.Errorf("failed to prepare rego query %q: %w", query, err) - } - - names := make([]string, 0, len(modules)) - for name := range modules { - names = append(names, name) - } - - return &Evaluator{ - preparedQuery: pq, - query: query, - runtimeConfig: runtimeConfig, - moduleNames: names, - }, nil -} - -// isURL checks if a source string looks like a remote URL. -func isURL(source string) bool { - return strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") -} - -// fetchPolicy downloads a .rego file from a URL and returns (filename, content, error). -func fetchPolicy(rawURL string) (string, string, error) { - parsed, err := url.Parse(rawURL) - if err != nil { - return "", "", fmt.Errorf("invalid URL: %w", err) - } - - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return "", "", fmt.Errorf("unsupported URL scheme %q (only http and https are supported)", parsed.Scheme) - } - - client := &http.Client{Timeout: policyFetchTimeout} - resp, err := client.Get(rawURL) - if err != nil { - return "", "", fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, rawURL) - } - - // Read with size limit - limited := io.LimitReader(resp.Body, maxPolicySize+1) - body, err := io.ReadAll(limited) - if err != nil { - return "", "", fmt.Errorf("failed to read response body: %w", err) - } - if len(body) > maxPolicySize { - return "", "", fmt.Errorf("policy file exceeds maximum size of %d bytes", maxPolicySize) - } - - // Derive filename from URL path - name := path.Base(parsed.Path) - if name == "" || name == "." || name == "/" { - name = "policy.rego" - } - if !strings.HasSuffix(name, ".rego") { - name += ".rego" - } - - return name, string(body), nil -} - -// Evaluate runs the compiled policy against a JSON message body. -// Returns a list of violation strings (empty = compliant). -func (e *Evaluator) Evaluate(ctx context.Context, body []byte) ([]string, error) { - var input interface{} - if err := json.Unmarshal(body, &input); err != nil { - return nil, fmt.Errorf("failed to parse message body as JSON: %w", err) - } - - rs, err := e.preparedQuery.Eval(ctx, rego.EvalInput(input)) - if err != nil { - return nil, fmt.Errorf("rego evaluation failed: %w", err) - } - - return extractViolations(rs) -} - -// extractViolations pulls string violations from the OPA result set. -// The query is expected to return a set of strings. -func extractViolations(rs rego.ResultSet) ([]string, error) { - if len(rs) == 0 { - return nil, nil - } - - var violations []string - for _, result := range rs { - for _, expr := range result.Expressions { - switch v := expr.Value.(type) { - case []interface{}: - // Result is a list (from set) - for _, item := range v { - if s, ok := item.(string); ok { - violations = append(violations, s) - } - } - case map[string]interface{}: - // OPA sometimes returns sets as maps with string keys - for key := range v { - violations = append(violations, key) - } - } - } - } - - return violations, nil -} - -// toInterfaceMap converts map[string]string to map[string]interface{} for OPA store. -func toInterfaceMap(m map[string]string) map[string]interface{} { - result := make(map[string]interface{}, len(m)) - for k, v := range m { - result[k] = v - } - return result -} diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go index d201945..14be720 100644 --- a/pkg/plugin/manager.go +++ b/pkg/plugin/manager.go @@ -257,21 +257,21 @@ func (m *Manager) Step(ctx context.Context, cfg *Config) (definition.Step, error return step, error } -// PolicyEnforcer returns a PolicyEnforcer instance based on the provided configuration. +// PolicyChecker returns a PolicyChecker instance based on the provided configuration. // It registers a cleanup function for resource management. -func (m *Manager) PolicyEnforcer(ctx context.Context, cfg *Config) (definition.PolicyEnforcer, error) { - pp, err := provider[definition.PolicyEnforcerProvider](m.plugins, cfg.ID) +func (m *Manager) PolicyChecker(ctx context.Context, cfg *Config) (definition.PolicyChecker, error) { + pp, err := provider[definition.PolicyCheckerProvider](m.plugins, cfg.ID) if err != nil { return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) } - enforcer, closer, err := pp.New(ctx, cfg.Config) + checker, closer, err := pp.New(ctx, cfg.Config) if err != nil { return nil, err } if closer != nil { m.closers = append(m.closers, closer) } - return enforcer, nil + return checker, nil } // Cache returns a Cache instance based on the provided configuration. diff --git a/policies/compliance.rego b/policies/compliance.rego deleted file mode 100644 index 5ab3793..0000000 --- a/policies/compliance.rego +++ /dev/null @@ -1,18 +0,0 @@ -package policy - -import rego.v1 - -# Example policy: validate confirm action messages. -# This is a sample policy file. Replace with your actual business rules. -# -# The policy evaluates incoming beckn messages and produces a set of -# violation strings. If any violations exist, the adapter will NACK -# the request. -# -# Available inputs: -# - input: the full JSON message body -# - data.config: runtime config from the adapter config (e.g., minDeliveryLeadHours) - -# violations is the set of policy violation messages. -# An empty set means the message is compliant. -violations := set() From 13a7a18e1753699db46f97c492cd424467d9cd0c Mon Sep 17 00:00:00 2001 From: Ritesh Date: Tue, 24 Mar 2026 17:59:48 +0530 Subject: [PATCH 7/8] Fix: address policy checker review feedback --- cmd/adapter/main_test.go | 57 ++++++++++++ core/module/module_test.go | 59 ++++++++++++- .../implementation/opapolicychecker/README.md | 9 +- .../opapolicychecker/benchmark_test.go | 12 +-- .../opapolicychecker/cmd/plugin_test.go | 41 +++++++++ .../opapolicychecker/enforcer.go | 70 +++++++++++---- .../opapolicychecker/enforcer_test.go | 86 +++++++++++++++---- .../opapolicychecker/evaluator.go | 36 ++++---- 8 files changed, 313 insertions(+), 57 deletions(-) create mode 100644 pkg/plugin/implementation/opapolicychecker/cmd/plugin_test.go diff --git a/cmd/adapter/main_test.go b/cmd/adapter/main_test.go index cd18015..5c003a5 100644 --- a/cmd/adapter/main_test.go +++ b/cmd/adapter/main_test.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -13,6 +14,7 @@ import ( "github.com/beckn-one/beckn-onix/core/module" "github.com/beckn-one/beckn-onix/core/module/handler" + "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" "github.com/stretchr/testify/mock" @@ -21,6 +23,15 @@ import ( // MockPluginManager implements handler.PluginManager for testing. type MockPluginManager struct { mock.Mock + policyCheckerFunc func(ctx context.Context, cfg *plugin.Config) (definition.PolicyChecker, error) +} + +type stubPolicyChecker struct { + err error +} + +func (s stubPolicyChecker) CheckPolicy(*model.StepContext) error { + return s.err } // Middleware returns a middleware function based on the provided configuration. @@ -85,6 +96,9 @@ func (m *MockPluginManager) SchemaValidator(ctx context.Context, cfg *plugin.Con // PolicyChecker returns a mock implementation of the PolicyChecker interface. func (m *MockPluginManager) PolicyChecker(ctx context.Context, cfg *plugin.Config) (definition.PolicyChecker, error) { + if m.policyCheckerFunc != nil { + return m.policyCheckerFunc(ctx, cfg) + } return nil, nil } @@ -335,6 +349,49 @@ func TestNewServerSuccess(t *testing.T) { } } +func TestNewServerRejectsPolicyViolation(t *testing.T) { + mockMgr := &MockPluginManager{ + policyCheckerFunc: func(ctx context.Context, cfg *plugin.Config) (definition.PolicyChecker, error) { + return stubPolicyChecker{err: model.NewBadReqErr(errors.New("blocked by policy"))}, nil + }, + } + + cfg := &Config{ + Modules: []module.Config{ + { + Name: "policy-module", + Path: "/policy", + Handler: handler.Config{ + Type: handler.HandlerTypeStd, + Plugins: handler.PluginCfg{ + PolicyChecker: &plugin.Config{ID: "mock-policy"}, + }, + Steps: []string{"checkPolicy"}, + }, + }, + }, + } + + h, err := newServer(context.Background(), mockMgr, cfg) + if err != nil { + t.Fatalf("expected no error creating server, got %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/policy", strings.NewReader(`{"context":{"action":"confirm"}}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for policy violation, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "NACK") { + t.Fatalf("expected NACK response, got %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "blocked by policy") { + t.Fatalf("expected policy error in response, got %s", rec.Body.String()) + } +} + // TestNewServerFailure tests failure scenarios when creating a server. func TestNewServerFailure(t *testing.T) { tests := []struct { diff --git a/core/module/module_test.go b/core/module/module_test.go index 3f26c4c..c050e70 100644 --- a/core/module/module_test.go +++ b/core/module/module_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "github.com/beckn-one/beckn-onix/core/module/handler" @@ -16,7 +17,8 @@ import ( // mockPluginManager is a mock implementation of the PluginManager interface // with support for dynamically setting behavior. type mockPluginManager struct { - middlewareFunc func(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) + middlewareFunc func(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) + policyCheckerFunc func(ctx context.Context, cfg *plugin.Config) (definition.PolicyChecker, error) } // Middleware returns a mock middleware function based on the provided configuration. @@ -81,10 +83,65 @@ func (m *mockPluginManager) SchemaValidator(ctx context.Context, cfg *plugin.Con // PolicyChecker returns a mock policy checker implementation. func (m *mockPluginManager) PolicyChecker(ctx context.Context, cfg *plugin.Config) (definition.PolicyChecker, error) { + if m.policyCheckerFunc != nil { + return m.policyCheckerFunc(ctx, cfg) + } return nil, nil } +type mockPolicyChecker struct { + err error +} + +func (m mockPolicyChecker) CheckPolicy(*model.StepContext) error { + return m.err +} + // TestRegisterSuccess tests scenarios where the handler registration should succeed. +func TestRegisterRejectsPolicyViolation(t *testing.T) { + mCfgs := []Config{ + { + Name: "test-module", + Path: "/test", + Handler: handler.Config{ + Type: handler.HandlerTypeStd, + Plugins: handler.PluginCfg{ + PolicyChecker: &plugin.Config{ID: "mock-policy"}, + }, + Steps: []string{"checkPolicy"}, + }, + }, + } + + mockManager := &mockPluginManager{ + middlewareFunc: func(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) { + return func(next http.Handler) http.Handler { return next }, nil + }, + policyCheckerFunc: func(ctx context.Context, cfg *plugin.Config) (definition.PolicyChecker, error) { + return mockPolicyChecker{err: model.NewBadReqErr(errors.New("blocked by policy"))}, nil + }, + } + + mux := http.NewServeMux() + if err := Register(context.Background(), mCfgs, mux, mockManager); err != nil { + t.Fatalf("unexpected register error: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"context":{"action":"confirm"}}`)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for policy violation, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "NACK") { + t.Fatalf("expected NACK response, got %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "blocked by policy") { + t.Fatalf("expected policy error in response, got %s", rec.Body.String()) + } +} + func TestRegisterSuccess(t *testing.T) { mCfgs := []Config{ { diff --git a/pkg/plugin/implementation/opapolicychecker/README.md b/pkg/plugin/implementation/opapolicychecker/README.md index 159fdf4..0e18c9e 100644 --- a/pkg/plugin/implementation/opapolicychecker/README.md +++ b/pkg/plugin/implementation/opapolicychecker/README.md @@ -10,6 +10,8 @@ Validates incoming Beckn messages against network-defined business rules using [ - Fail-closed on empty/undefined query results — misconfigured policies are treated as violations - Runtime config forwarding: adapter config values are accessible in Rego as `data.config.` - Action-based enforcement: apply policies only to specific beckn actions (e.g., `confirm`, `search`) +- Configurable fetch timeout for remote policy and bundle sources +- Warns at startup when policy enforcement is explicitly disabled ## Configuration @@ -36,6 +38,7 @@ steps: | `actions` | string | No | *(all)* | Comma-separated beckn actions to enforce | | `enabled` | string | No | `"true"` | Enable or disable the plugin | | `debugLogging` | string | No | `"false"` | Enable verbose OPA evaluation logging | +| `fetchTimeoutSeconds` | string | No | `"30"` | Timeout in seconds for fetching remote `.rego` files or bundles | | `refreshIntervalSeconds` | string | No | - | Reload policies every N seconds (0 or omit = disabled) | | *any other key* | string | No | - | Forwarded to Rego as `data.config.` | @@ -47,7 +50,7 @@ When `refreshIntervalSeconds` is set, a background goroutine periodically re-fet - **Atomic swap**: the old evaluator stays fully active until the new one is compiled — no gap in enforcement - **Non-fatal errors**: if the reload fails (e.g., file temporarily unreachable or parse error), the error is logged and the previous policy stays active -- **Goroutine lifecycle**: the reload loop is tied to the adapter context and stops cleanly on shutdown +- **Goroutine lifecycle**: the reload loop stops when the adapter context is cancelled or when plugin `Close()` is invoked during shutdown ```yaml config: @@ -67,7 +70,7 @@ config: ### Request Evaluation (Runtime) -1. **Check Action Match**: If `actions` is configured, skip evaluation for non-matching actions +1. **Check Action Match**: If `actions` is configured, skip evaluation for non-matching actions. The plugin assumes standard adapter routes look like `/{participant}/{direction}/{action}` such as `/bpp/caller/confirm`; non-standard paths fall back to `context.action` from the JSON body. 2. **Evaluate OPA Query**: Run the prepared query with the full beckn message as `input` 3. **Handle Result**: - If the query returns no result (undefined) → **violation** (fail-closed) @@ -109,6 +112,7 @@ checkPolicy: type: url location: https://policies.example.com/compliance.rego query: "data.policy.result" + fetchTimeoutSeconds: "10" ``` ### Local Directory (multiple `.rego` files) @@ -192,3 +196,4 @@ Configure them side-by-side in your adapter steps as needed. - **No bundle signature verification**: When using `type: bundle`, bundle signature verification is skipped. This is planned for a future enhancement. - **Network-level scoping**: Policies apply to all messages handled by the adapter instance. Per-network policy mapping (by `networkId`) is tracked for follow-up. +- **Non-standard route shapes**: URL-based action extraction assumes the standard Beckn adapter route shape `/{participant}/{direction}/{action}` and falls back to `context.action` for other path layouts. diff --git a/pkg/plugin/implementation/opapolicychecker/benchmark_test.go b/pkg/plugin/implementation/opapolicychecker/benchmark_test.go index f070905..1f10a08 100644 --- a/pkg/plugin/implementation/opapolicychecker/benchmark_test.go +++ b/pkg/plugin/implementation/opapolicychecker/benchmark_test.go @@ -101,7 +101,7 @@ func BenchmarkEvaluate_MostlyInactive(b *testing.B) { dir := b.TempDir() os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { b.Fatalf("NewEvaluator failed: %v", err) } @@ -135,7 +135,7 @@ func BenchmarkEvaluate_AllActive(b *testing.B) { dir := b.TempDir() os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateActiveRules(n)), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { b.Fatalf("NewEvaluator failed: %v", err) } @@ -172,7 +172,7 @@ func BenchmarkCompilation(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + _, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { b.Fatalf("NewEvaluator failed: %v", err) } @@ -206,7 +206,7 @@ func TestBenchmarkReport(t *testing.T) { os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644) start := time.Now() - _, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + _, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) elapsed := time.Since(start) if err != nil { t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err) @@ -227,7 +227,7 @@ func TestBenchmarkReport(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateDummyRules(n)), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err) } @@ -263,7 +263,7 @@ func TestBenchmarkReport(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(generateActiveRules(n)), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator(%d rules) failed: %v", n, err) } diff --git a/pkg/plugin/implementation/opapolicychecker/cmd/plugin_test.go b/pkg/plugin/implementation/opapolicychecker/cmd/plugin_test.go new file mode 100644 index 0000000..8ed77c7 --- /dev/null +++ b/pkg/plugin/implementation/opapolicychecker/cmd/plugin_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "path/filepath" + "testing" +) + +func TestProviderNewSuccess(t *testing.T) { + provider := provider{} + config := map[string]string{ + "type": "file", + "location": filepath.Join("..", "testdata", "example.rego"), + "query": "data.policy.result", + } + + checker, closer, err := provider.New(context.Background(), config) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + if checker == nil { + t.Fatal("New() returned nil checker") + } + if closer == nil { + t.Fatal("New() returned nil closer") + } + + closer() +} + +func TestProviderNewFailure(t *testing.T) { + provider := provider{} + + _, _, err := provider.New(context.Background(), map[string]string{ + "type": "file", + "query": "data.policy.result", + }) + if err == nil { + t.Fatal("expected error when required config is missing") + } +} diff --git a/pkg/plugin/implementation/opapolicychecker/enforcer.go b/pkg/plugin/implementation/opapolicychecker/enforcer.go index 0dd631c..94fb0bb 100644 --- a/pkg/plugin/implementation/opapolicychecker/enforcer.go +++ b/pkg/plugin/implementation/opapolicychecker/enforcer.go @@ -22,6 +22,7 @@ type Config struct { Actions []string Enabled bool DebugLogging bool + FetchTimeout time.Duration IsBundle bool RefreshInterval time.Duration // 0 = disabled RuntimeConfig map[string]string @@ -34,12 +35,14 @@ var knownKeys = map[string]bool{ "actions": true, "enabled": true, "debugLogging": true, + "fetchTimeoutSeconds": true, "refreshIntervalSeconds": true, } func DefaultConfig() *Config { return &Config{ Enabled: true, + FetchTimeout: defaultPolicyFetchTimeout, RuntimeConfig: make(map[string]string), } } @@ -105,6 +108,14 @@ func ParseConfig(cfg map[string]string) (*Config, error) { config.DebugLogging = debug == "true" || debug == "1" } + if fts, ok := cfg["fetchTimeoutSeconds"]; ok && fts != "" { + secs, err := strconv.Atoi(fts) + if err != nil || secs <= 0 { + return nil, fmt.Errorf("'fetchTimeoutSeconds' must be a positive integer, got %q", fts) + } + config.FetchTimeout = time.Duration(secs) * time.Second + } + if ris, ok := cfg["refreshIntervalSeconds"]; ok && ris != "" { secs, err := strconv.Atoi(ris) if err != nil || secs < 0 { @@ -136,9 +147,11 @@ func (c *Config) IsActionEnabled(action string) bool { // PolicyEnforcer evaluates beckn messages against OPA policies and NACKs non-compliant messages. type PolicyEnforcer struct { - config *Config - evaluator *Evaluator - evaluatorMu sync.RWMutex + config *Config + evaluator *Evaluator + evaluatorMu sync.RWMutex + closeOnce sync.Once + done chan struct{} } // getEvaluator safely returns the current evaluator under a read lock. @@ -162,18 +175,24 @@ func New(ctx context.Context, cfg map[string]string) (*PolicyEnforcer, error) { return nil, fmt.Errorf("opapolicychecker: config error: %w", err) } - evaluator, err := NewEvaluator(config.PolicyPaths, config.Query, config.RuntimeConfig, config.IsBundle) + enforcer := &PolicyEnforcer{ + config: config, + done: make(chan struct{}), + } + + if !config.Enabled { + log.Warnf(ctx, "OPAPolicyChecker is disabled via config; policy enforcement will be skipped") + return enforcer, nil + } + + evaluator, err := NewEvaluator(config.PolicyPaths, config.Query, config.RuntimeConfig, config.IsBundle, config.FetchTimeout) if err != nil { return nil, fmt.Errorf("opapolicychecker: failed to initialize OPA evaluator: %w", err) } + enforcer.evaluator = evaluator - log.Infof(ctx, "OPAPolicyChecker initialized (actions=%v, query=%s, policies=%v, isBundle=%v, debugLogging=%v, refreshInterval=%s)", - config.Actions, config.Query, evaluator.ModuleNames(), config.IsBundle, config.DebugLogging, config.RefreshInterval) - - enforcer := &PolicyEnforcer{ - config: config, - evaluator: evaluator, - } + log.Infof(ctx, "OPAPolicyChecker initialized (actions=%v, query=%s, policies=%v, isBundle=%v, debugLogging=%v, fetchTimeout=%s, refreshInterval=%s)", + config.Actions, config.Query, evaluator.ModuleNames(), config.IsBundle, config.DebugLogging, config.FetchTimeout, config.RefreshInterval) if config.RefreshInterval > 0 { go enforcer.refreshLoop(ctx) @@ -193,6 +212,9 @@ func (e *PolicyEnforcer) refreshLoop(ctx context.Context) { case <-ctx.Done(): log.Infof(ctx, "OPAPolicyChecker: refresh loop stopped") return + case <-e.done: + log.Infof(ctx, "OPAPolicyChecker: refresh loop stopped by Close()") + return case <-ticker.C: e.reloadPolicies(ctx) } @@ -208,6 +230,7 @@ func (e *PolicyEnforcer) reloadPolicies(ctx context.Context) { e.config.Query, e.config.RuntimeConfig, e.config.IsBundle, + e.config.FetchTimeout, ) if err != nil { log.Errorf(ctx, err, "OPAPolicyChecker: policy reload failed (keeping previous policies): %v", err) @@ -237,6 +260,9 @@ func (e *PolicyEnforcer) CheckPolicy(ctx *model.StepContext) error { } ev := e.getEvaluator() + if ev == nil { + return model.NewBadReqErr(fmt.Errorf("policy evaluator is not initialized")) + } if e.config.DebugLogging { log.Debugf(ctx, "OPAPolicyChecker: evaluating policies for action %q (modules=%v)", action, ev.ModuleNames()) @@ -260,12 +286,17 @@ func (e *PolicyEnforcer) CheckPolicy(ctx *model.StepContext) error { return model.NewBadReqErr(fmt.Errorf("%s", msg)) } -func (e *PolicyEnforcer) Close() {} +func (e *PolicyEnforcer) Close() { + e.closeOnce.Do(func() { + close(e.done) + }) +} func extractAction(urlPath string, body []byte) string { - parts := strings.Split(strings.Trim(urlPath, "/"), "/") - if len(parts) >= 3 { - return parts[len(parts)-1] + // /bpp/caller/confirm/extra as action "extra". + parts := strings.FieldsFunc(strings.Trim(urlPath, "/"), func(r rune) bool { return r == '/' }) + if len(parts) == 3 && isBecknDirection(parts[1]) && parts[2] != "" { + return parts[2] } var payload struct { @@ -279,3 +310,12 @@ func extractAction(urlPath string, body []byte) string { return "" } + +func isBecknDirection(part string) bool { + switch part { + case "caller", "receiver", "reciever": + return true + default: + return false + } +} diff --git a/pkg/plugin/implementation/opapolicychecker/enforcer_test.go b/pkg/plugin/implementation/opapolicychecker/enforcer_test.go index 2aba5a2..7d0e8a9 100644 --- a/pkg/plugin/implementation/opapolicychecker/enforcer_test.go +++ b/pkg/plugin/implementation/opapolicychecker/enforcer_test.go @@ -141,7 +141,7 @@ violations contains msg if { } ` dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -165,7 +165,7 @@ violations contains msg if { } ` dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -192,7 +192,7 @@ violations contains msg if { } ` dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", map[string]string{"maxValue": "100"}, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", map[string]string{"maxValue": "100"}, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -235,7 +235,7 @@ test_something if { count(policy.violations) > 0 } ` os.WriteFile(filepath.Join(dir, "policy_test.rego"), []byte(testFile), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator should skip _test.rego files, but failed: %v", err) } @@ -256,7 +256,7 @@ import rego.v1 violations := set() ` dir := writePolicyDir(t, "test.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -285,7 +285,7 @@ violations contains msg if { })) defer srv.Close() - eval, err := NewEvaluator([]string{srv.URL + "/test_policy.rego"}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{srv.URL + "/test_policy.rego"}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator with URL failed: %v", err) } @@ -313,14 +313,14 @@ func TestEvaluator_FetchURL_NotFound(t *testing.T) { srv := httptest.NewServer(http.NotFoundHandler()) defer srv.Close() - _, err := NewEvaluator([]string{srv.URL + "/missing.rego"}, "data.policy.violations", nil, false) + _, err := NewEvaluator([]string{srv.URL + "/missing.rego"}, "data.policy.violations", nil, false, 0) if err == nil { t.Fatal("expected error for 404 URL") } } func TestEvaluator_FetchURL_InvalidScheme(t *testing.T) { - _, err := NewEvaluator([]string{"ftp://example.com/policy.rego"}, "data.policy.violations", nil, false) + _, err := NewEvaluator([]string{"ftp://example.com/policy.rego"}, "data.policy.violations", nil, false, 0) if err == nil { t.Fatal("expected error for ftp:// scheme") } @@ -346,7 +346,7 @@ violations contains "remote_violation" if { input.remote_bad } })) defer srv.Close() - eval, err := NewEvaluator([]string{dir, srv.URL + "/remote.rego"}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir, srv.URL + "/remote.rego"}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -373,7 +373,7 @@ violations contains "from_file" if { input.bad } policyPath := filepath.Join(dir, "local_policy.rego") os.WriteFile(policyPath, []byte(policy), 0644) - eval, err := NewEvaluator([]string{policyPath}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{policyPath}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator with local path failed: %v", err) } @@ -412,7 +412,7 @@ violations contains "order too large" if { is_high_value } ` os.WriteFile(filepath.Join(dir, "rules.rego"), []byte(rules), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -462,7 +462,7 @@ violations contains "high value confirm blocked" if { ` os.WriteFile(filepath.Join(dir, "rules.rego"), []byte(rules), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -531,7 +531,7 @@ violations contains "safety: order value too high" if { ` os.WriteFile(filepath.Join(dir, "safety.rego"), []byte(safety), 0644) - eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.violations", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -744,7 +744,7 @@ default result := { } ` dir := writePolicyDir(t, "policy.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.retail.policy.result", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.retail.policy.result", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -782,7 +782,7 @@ violations contains msg if { } ` dir := writePolicyDir(t, "policy.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.retail.policy.result", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.retail.policy.result", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -841,7 +841,7 @@ result := { } ` dir := writePolicyDir(t, "policy.rego", policy) - eval, err := NewEvaluator([]string{dir}, "data.policy.result", nil, false) + eval, err := NewEvaluator([]string{dir}, "data.policy.result", nil, false, 0) if err != nil { t.Fatalf("NewEvaluator failed: %v", err) } @@ -912,7 +912,7 @@ violations contains msg if { })) defer srv.Close() - eval, err := NewEvaluator([]string{srv.URL + "/bundle.tar.gz"}, "data.retail.validation.result", nil, true) + eval, err := NewEvaluator([]string{srv.URL + "/bundle.tar.gz"}, "data.retail.validation.result", nil, true, 0) if err != nil { t.Fatalf("NewEvaluator with bundle failed: %v", err) } @@ -1096,3 +1096,55 @@ default result := {"valid": true, "violations": []} t.Fatal("hot-reload did not take effect within 5 seconds") } +func TestParseConfig_FetchTimeout(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "type": "url", + "location": "https://example.com/policy.rego", + "query": "data.policy.violations", + "fetchTimeoutSeconds": "7", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.FetchTimeout != 7*time.Second { + t.Fatalf("expected fetch timeout 7s, got %s", cfg.FetchTimeout) + } +} + +func TestEvaluator_FetchURL_Timeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + w.Write([]byte(`package policy +import rego.v1 +violations := []`)) + })) + defer srv.Close() + + _, err := NewEvaluator([]string{srv.URL + "/slow.rego"}, "data.policy.violations", nil, false, 10*time.Millisecond) + if err == nil { + t.Fatal("expected timeout error for slow policy URL") + } +} + +func TestExtractAction_NonStandardURLFallsBackToBody(t *testing.T) { + body := []byte(`{"context": {"action": "confirm"}}`) + action := extractAction("/bpp/caller/confirm/extra", body) + if action != "confirm" { + t.Fatalf("expected body fallback action 'confirm', got %q", action) + } +} + +func TestEnforcer_DisabledSkipsEvaluatorInitialization(t *testing.T) { + enforcer, err := New(context.Background(), map[string]string{ + "type": "url", + "location": "https://127.0.0.1:1/unreachable.rego", + "query": "data.policy.violations", + "enabled": "false", + }) + if err != nil { + t.Fatalf("expected disabled enforcer to skip evaluator initialization, got %v", err) + } + if enforcer.getEvaluator() != nil { + t.Fatal("expected disabled enforcer to leave evaluator uninitialized") + } +} diff --git a/pkg/plugin/implementation/opapolicychecker/evaluator.go b/pkg/plugin/implementation/opapolicychecker/evaluator.go index aff3584..dc57f46 100644 --- a/pkg/plugin/implementation/opapolicychecker/evaluator.go +++ b/pkg/plugin/implementation/opapolicychecker/evaluator.go @@ -35,8 +35,9 @@ func (e *Evaluator) ModuleNames() []string { return e.moduleNames } -// policyFetchTimeout is the HTTP timeout for fetching remote .rego files. -const policyFetchTimeout = 30 * time.Second +// defaultPolicyFetchTimeout bounds remote policy and bundle fetches during startup +// and refresh. This can be overridden via config.fetchTimeoutSeconds. +const defaultPolicyFetchTimeout = 30 * time.Second // maxPolicySize is the maximum size of a single .rego file fetched from a URL (1 MB). const maxPolicySize = 1 << 20 @@ -47,21 +48,24 @@ const maxBundleSize = 10 << 20 // NewEvaluator creates an Evaluator by loading .rego files from local paths // and/or URLs, then compiling them. runtimeConfig is passed to Rego as data.config. // When isBundle is true, the first policyPath is treated as a URL to an OPA bundle (.tar.gz). -func NewEvaluator(policyPaths []string, query string, runtimeConfig map[string]string, isBundle bool) (*Evaluator, error) { - if isBundle { - return newBundleEvaluator(policyPaths, query, runtimeConfig) +func NewEvaluator(policyPaths []string, query string, runtimeConfig map[string]string, isBundle bool, fetchTimeout time.Duration) (*Evaluator, error) { + if fetchTimeout <= 0 { + fetchTimeout = defaultPolicyFetchTimeout } - return newRegoEvaluator(policyPaths, query, runtimeConfig) + if isBundle { + return newBundleEvaluator(policyPaths, query, runtimeConfig, fetchTimeout) + } + return newRegoEvaluator(policyPaths, query, runtimeConfig, fetchTimeout) } // newRegoEvaluator loads raw .rego files from local paths and/or URLs. -func newRegoEvaluator(policyPaths []string, query string, runtimeConfig map[string]string) (*Evaluator, error) { +func newRegoEvaluator(policyPaths []string, query string, runtimeConfig map[string]string, fetchTimeout time.Duration) (*Evaluator, error) { modules := make(map[string]string) // Load from policyPaths (resolved locations based on config Type) for _, source := range policyPaths { if isURL(source) { - name, content, err := fetchPolicy(source) + name, content, err := fetchPolicy(source, fetchTimeout) if err != nil { return nil, fmt.Errorf("failed to fetch policy from %s: %w", source, err) } @@ -101,13 +105,13 @@ func newRegoEvaluator(policyPaths []string, query string, runtimeConfig map[stri } // newBundleEvaluator loads an OPA bundle (.tar.gz) from a URL and compiles it. -func newBundleEvaluator(policyPaths []string, query string, runtimeConfig map[string]string) (*Evaluator, error) { +func newBundleEvaluator(policyPaths []string, query string, runtimeConfig map[string]string, fetchTimeout time.Duration) (*Evaluator, error) { if len(policyPaths) == 0 { return nil, fmt.Errorf("bundle source URL is required") } bundleURL := policyPaths[0] - modules, bundleData, err := loadBundle(bundleURL) + modules, bundleData, err := loadBundle(bundleURL, fetchTimeout) if err != nil { return nil, fmt.Errorf("failed to load bundle from %s: %w", bundleURL, err) } @@ -121,8 +125,8 @@ func newBundleEvaluator(policyPaths []string, query string, runtimeConfig map[st // loadBundle downloads a .tar.gz OPA bundle from a URL, parses it using OPA's // bundle reader, and returns the modules and data from the bundle. -func loadBundle(bundleURL string) (map[string]string, map[string]interface{}, error) { - data, err := fetchBundleArchive(bundleURL) +func loadBundle(bundleURL string, fetchTimeout time.Duration) (map[string]string, map[string]interface{}, error) { + data, err := fetchBundleArchive(bundleURL, fetchTimeout) if err != nil { return nil, nil, err } @@ -131,7 +135,7 @@ func loadBundle(bundleURL string) (map[string]string, map[string]interface{}, er } // fetchBundleArchive downloads a bundle .tar.gz from a URL. -func fetchBundleArchive(rawURL string) ([]byte, error) { +func fetchBundleArchive(rawURL string, fetchTimeout time.Duration) ([]byte, error) { parsed, err := url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("invalid URL: %w", err) @@ -141,7 +145,7 @@ func fetchBundleArchive(rawURL string) ([]byte, error) { return nil, fmt.Errorf("unsupported URL scheme %q (only http and https are supported)", parsed.Scheme) } - client := &http.Client{Timeout: policyFetchTimeout} + client := &http.Client{Timeout: fetchTimeout} resp, err := client.Get(rawURL) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) @@ -229,7 +233,7 @@ func isURL(source string) bool { } // fetchPolicy downloads a .rego file from a URL and returns (filename, content, error). -func fetchPolicy(rawURL string) (string, string, error) { +func fetchPolicy(rawURL string, fetchTimeout time.Duration) (string, string, error) { parsed, err := url.Parse(rawURL) if err != nil { return "", "", fmt.Errorf("invalid URL: %w", err) @@ -239,7 +243,7 @@ func fetchPolicy(rawURL string) (string, string, error) { return "", "", fmt.Errorf("unsupported URL scheme %q (only http and https are supported)", parsed.Scheme) } - client := &http.Client{Timeout: policyFetchTimeout} + client := &http.Client{Timeout: fetchTimeout} resp, err := client.Get(rawURL) if err != nil { return "", "", fmt.Errorf("HTTP request failed: %w", err) From 97e63cf7d2685cff8c0f33fc6e6322588ebfb059 Mon Sep 17 00:00:00 2001 From: Ritesh Date: Wed, 25 Mar 2026 14:47:04 +0530 Subject: [PATCH 8/8] fix: remove unsupported map fallback in policy result parsing --- .../opapolicychecker/enforcer_test.go | 26 +++++++++++++++++++ .../opapolicychecker/evaluator.go | 6 ----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/pkg/plugin/implementation/opapolicychecker/enforcer_test.go b/pkg/plugin/implementation/opapolicychecker/enforcer_test.go index 7d0e8a9..3d42f4c 100644 --- a/pkg/plugin/implementation/opapolicychecker/enforcer_test.go +++ b/pkg/plugin/implementation/opapolicychecker/enforcer_test.go @@ -855,6 +855,32 @@ result := { } } +func TestEvaluator_NonStructuredMapResult_Ignored(t *testing.T) { + policy := ` +package policy + +import rego.v1 + +result := { + "action": "confirm", + "status": "ok" +} +` + dir := writePolicyDir(t, "policy.rego", policy) + eval, err := NewEvaluator([]string{dir}, "data.policy.result", nil, false, 0) + if err != nil { + t.Fatalf("NewEvaluator failed: %v", err) + } + + violations, err := eval.Evaluate(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if len(violations) != 0 { + t.Fatalf("expected non-structured map result to be ignored, got %v", violations) + } +} + // --- Bundle Tests --- // buildTestBundle creates an OPA bundle .tar.gz in memory from the given modules. diff --git a/pkg/plugin/implementation/opapolicychecker/evaluator.go b/pkg/plugin/implementation/opapolicychecker/evaluator.go index dc57f46..cc0ff9e 100644 --- a/pkg/plugin/implementation/opapolicychecker/evaluator.go +++ b/pkg/plugin/implementation/opapolicychecker/evaluator.go @@ -332,14 +332,8 @@ func extractViolations(rs rego.ResultSet) ([]string, error) { } } case map[string]interface{}: - // Check for structured result: {"valid": bool, "violations": [...]} if vs := extractStructuredViolations(v); vs != nil { violations = append(violations, vs...) - } else { - // Fallback: OPA sometimes returns sets as maps with string keys - for key := range v { - violations = append(violations, key) - } } } }