From 40dd2b90d2a9d05c0d173962cbe19fe07879c172 Mon Sep 17 00:00:00 2001 From: Sebastian Spaink Date: Tue, 4 Aug 2026 11:05:36 -0500 Subject: [PATCH] config: migrate server.encoding and server.decoding validation to Rego (#8903) Follow-up to #8900. Moves the gzip encoding and decoding config validation off the Go `validateAndInjectDefaults` methods and onto embedded Rego policies, injecting defaults and reporting value errors. Each config registers its recognized options via `config.RegisterConfigSpec` so unknown-option warnings live with the owning struct. Field type validation stays in the Go decode step. --------- Signed-off-by: Sebastian Spaink --- .regal/config.yaml | 3 + build/run-rego-tests.sh | 13 ++- internal/configpolicy/configpolicy.go | 30 +++++- internal/configpolicy/configpolicy_test.go | 31 ++++++ internal/configpolicy/util.rego | 45 +++++++++ internal/metricsexport/validate.rego | 15 ++- v1/config/validate.rego | 16 +-- v1/plugins/server/decoding/config.go | 66 +++++-------- v1/plugins/server/decoding/config_test.go | 97 +++++++++++++++++++ v1/plugins/server/decoding/validate.rego | 37 +++++++ v1/plugins/server/decoding/validate_test.rego | 95 ++++++++++++++++++ v1/plugins/server/encoding/config.go | 73 +++++--------- v1/plugins/server/encoding/config_test.go | 97 +++++++++++++++++++ v1/plugins/server/encoding/validate.rego | 45 +++++++++ v1/plugins/server/encoding/validate_test.rego | 91 +++++++++++++++++ v1/plugins/server/metrics/config.go | 8 +- v1/plugins/server/metrics/config_test.go | 97 +++++++++++++++++++ v1/plugins/server/metrics/validate.rego | 51 ++++++++-- v1/plugins/server/metrics/validate_test.rego | 56 +++++++++++ v1/runtime/runtime.go | 6 +- v1/server/server.go | 12 +-- 21 files changed, 844 insertions(+), 140 deletions(-) create mode 100644 internal/configpolicy/util.rego create mode 100644 v1/plugins/server/decoding/validate.rego create mode 100644 v1/plugins/server/decoding/validate_test.rego create mode 100644 v1/plugins/server/encoding/validate.rego create mode 100644 v1/plugins/server/encoding/validate_test.rego diff --git a/.regal/config.yaml b/.regal/config.yaml index e57f99b288..a6862fdd23 100644 --- a/.regal/config.yaml +++ b/.regal/config.yaml @@ -14,8 +14,11 @@ rules: - "docs" - "internal/wasm/sdk/examples/*" - "v1/config/*.rego" + - "internal/configpolicy/*.rego" - "internal/metricsexport/*.rego" - "v1/plugins/server/metrics/*.rego" + - "v1/plugins/server/encoding/*.rego" + - "v1/plugins/server/decoding/*.rego" style: line-length: ignore: diff --git a/build/run-rego-tests.sh b/build/run-rego-tests.sh index 334af17a8c..11befd2c66 100755 --- a/build/run-rego-tests.sh +++ b/build/run-rego-tests.sh @@ -7,6 +7,11 @@ set -euo pipefail OPA="${OPA:-opa}" +# The config validation policies import the helpers in this module, which the Go +# layer compiles into every policy, so it is loaded alongside each directory here. +CONFIG_UTIL=internal/configpolicy/util.rego +CONFIG_UTIL_DIR="./$(dirname "$CONFIG_UTIL")" + dirs=$(find . -name '*_test.rego' \ -not -path './build/policy/*' \ -not -path '*/testdata/*' \ @@ -23,8 +28,12 @@ fi status=0 for d in $dirs; do - echo "==> ${OPA} test ${d}" - "$OPA" test "$d" || status=1 + paths=("$d") + if [ "$d" != "$CONFIG_UTIL_DIR" ]; then + paths+=("$CONFIG_UTIL") + fi + echo "==> ${OPA} test ${paths[*]}" + "$OPA" test "${paths[@]}" || status=1 done exit "$status" diff --git a/internal/configpolicy/configpolicy.go b/internal/configpolicy/configpolicy.go index 65c0ee7b01..446de90c82 100644 --- a/internal/configpolicy/configpolicy.go +++ b/internal/configpolicy/configpolicy.go @@ -9,10 +9,14 @@ // processed - the input config with defaults injected (required) // errors - a set/array of fatal error strings (joined into one error) // warnings - a set/array of non-fatal warning strings +// +// Every policy is compiled together with util.rego, so a policy can import +// data.opa.config.util for the helpers shared across the validation policies. package configpolicy import ( "context" + _ "embed" "encoding/json" "errors" "fmt" @@ -26,6 +30,13 @@ import ( "github.com/open-policy-agent/opa/v1/util" ) +// The shared helpers are compiled into every policy, so a validation policy can +// import data.opa.config.util rather than repeat them. +const utilModuleName = "opa/config/util.rego" + +//go:embed util.rego +var utilModule string + // Policy is an embedded validation policy, compiled once on first use and // evaluated repeatedly. Safe for concurrent use. type Policy struct { @@ -110,14 +121,19 @@ func (p *Policy) Eval(ctx context.Context, input any) (map[string]any, []string, // EvalConfigInto decodes raw config bytes (absent/empty/null → empty object), // evaluates the policy with input {"config": } to inject defaults, and -// decodes the processed config into out, returning any warnings. The typed -// unmarshal into out is what enforces field types, so a mistyped option -// surfaces here rather than in the policy. +// decodes the processed config into out, returning any warnings. Field types the +// policy does not check are enforced by the typed unmarshal into out, so a +// mistyped option surfaces here rather than in the policy. func EvalConfigInto[T any](ctx context.Context, p *Policy, raw []byte, out *T) ([]string, error) { rawConfig, err := unmarshalRawConfig(raw) if err != nil { return nil, err } + if _, ok := rawConfig.(map[string]any); !ok { + // Caught here rather than in the policy, which would fail to produce a + // processed config and report the far less obvious infrastructure error. + return nil, fmt.Errorf("%s: config must be an object", p.name) + } processed, warnings, err := p.Eval(ctx, map[string]any{"config": rawConfig}) if err != nil { @@ -160,8 +176,14 @@ func (p *Policy) Compiler() (*ast.Compiler, error) { p.compileErr = fmt.Errorf("%s: %w", p.name, err) return } + helpers, err := ast.ParseModuleWithOpts(utilModuleName, utilModule, popts) + if err != nil { + p.compileErr = fmt.Errorf("%s: %w", utilModuleName, err) + return + } + modules := map[string]*ast.Module{p.name: module, utilModuleName: helpers} compiler := ast.NewCompiler() - if compiler.Compile(map[string]*ast.Module{p.name: module}); compiler.Failed() { + if compiler.Compile(modules); compiler.Failed() { p.compileErr = fmt.Errorf("%s: %w", p.name, compiler.Errors) return } diff --git a/internal/configpolicy/configpolicy_test.go b/internal/configpolicy/configpolicy_test.go index d886dcb7e6..603499ade9 100644 --- a/internal/configpolicy/configpolicy_test.go +++ b/internal/configpolicy/configpolicy_test.go @@ -5,6 +5,7 @@ package configpolicy import ( + "encoding/json" "reflect" "slices" "testing" @@ -95,6 +96,36 @@ func TestPolicyCompilerCompilesOnce(t *testing.T) { } } +const helpersModule = `package test.helpers + +import data.opa.config.util + +processed := object.union_n(array.concat([input.config], [patch | some patch in _patches])) + +_patches contains {"size": 10} if util.absent(["size"]) + +errors contains "size must be a positive number" if util.not_positive_number(["size"]) +` + +// The shared helpers are compiled into every policy, so a policy can use them +// without embedding its own copy. +func TestPolicySharedHelpers(t *testing.T) { + p := New("test/helpers.rego", helpersModule, "data.test.helpers = x") + + processed, _, err := p.Eval(t.Context(), map[string]any{"config": map[string]any{}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(processed, map[string]any{"size": json.Number("10")}) { + t.Fatalf("expected the helper-guarded default to be injected, got %v", processed) + } + + _, _, err = p.Eval(t.Context(), map[string]any{"config": map[string]any{"size": -1}}) + if err == nil || err.Error() != "size must be a positive number" { + t.Fatalf("expected the helper to reject a non-positive value, got %v", err) + } +} + const intoModule = `package test.into processed := object.union({"name": "default"}, input.config) diff --git a/internal/configpolicy/util.rego b/internal/configpolicy/util.rego new file mode 100644 index 0000000000..47a7d734e0 --- /dev/null +++ b/internal/configpolicy/util.rego @@ -0,0 +1,45 @@ +# METADATA +# description: | +# Helpers shared by the embedded configuration validation policies. Compiled +# into every configpolicy.Policy, so a policy can import data.opa.config.util +# instead of carrying its own copy of these rules. +# +# The helpers read the raw configuration from input.config, the part of the +# input document every validation policy is given. +package opa.config.util + +# METADATA +# description: the configured value at path, or null when the option is absent. +value(path) := object.get(input.config, path, null) + +# METADATA +# description: | +# true when the option at path is missing or explicitly null, the cases where a +# default is injected, matching the pre-Rego behavior where a nil pointer was +# replaced with a default. +absent(path) if value(path) == null + +# METADATA +# description: | +# true when the option at path is present but not an object. The shape of an +# option holding an object has to be checked in the policy rather than left to +# the Go unmarshal: the default patches would otherwise be merged over the bad +# value, silently replacing it with a well-formed object. +not_object(path) if { + v := value(path) + v != null + not is_object(v) +} + +# METADATA +# description: true when the option at path is present but not a number above zero. +not_positive_number(path) if { + v := value(path) + v != null + not _positive_number(v) +} + +_positive_number(v) if { + is_number(v) + v > 0 +} diff --git a/internal/metricsexport/validate.rego b/internal/metricsexport/validate.rego index 7ee0ccba08..e1a6f392ce 100644 --- a/internal/metricsexport/validate.rego +++ b/internal/metricsexport/validate.rego @@ -5,11 +5,13 @@ # handled by unmarshaling into the Go struct. # # Input: {"config": } -# Entrypoints: processed (config + defaults), errors (fatal). +# Rules read by the Go layer: processed (config + defaults), errors (fatal). package opa.config.metrics_export import future.keywords.not +import data.opa.config.util + _default_grpc_address := "localhost:4317" _default_http_address := "localhost:4318" @@ -31,19 +33,18 @@ _default_address := _default_grpc_address if lower(input.config.type) == "otlp/g _default_address := _default_http_address if lower(input.config.type) == "otlp/http" # METADATA -# entrypoint: true # description: the config with metrics_export defaults injected for absent options. processed := object.union_n(array.concat([input.config], [patch | some patch in _patches])) _patches contains {"address": _default_address} if _empty(["address"]) -_patches contains {"export_interval_ms": _default_export_interval_ms} if _absent(["export_interval_ms"]) +_patches contains {"export_interval_ms": _default_export_interval_ms} if util.absent(["export_interval_ms"]) _patches contains {"service_name": _default_service_name} if _empty(["service_name"]) _patches contains {"encryption": _default_encryption_scheme} if _empty(["encryption"]) -_patches contains {"allow_insecure_tls": false} if _absent(["allow_insecure_tls"]) +_patches contains {"allow_insecure_tls": false} if util.absent(["allow_insecure_tls"]) errors contains msg if { input.config.type @@ -61,16 +62,12 @@ errors contains msg if { msg := $`unsupported metrics_export.encryption "{processed.encryption}"` } -# _absent is true when the option at path is missing or explicitly null (a nil -# pointer in Go). -_absent(path) if object.get(input.config, path, null) == null - # _empty is true when a string option is missing, null, or the empty string, # matching the pre-Rego behavior that defaulted on "". _empty(path) if not _nonempty(path) _nonempty(path) if { - v := object.get(input.config, path, null) + v := util.value(path) v != null v != "" } diff --git a/v1/config/validate.rego b/v1/config/validate.rego index b97ba755fe..9511736d48 100644 --- a/v1/config/validate.rego +++ b/v1/config/validate.rego @@ -8,36 +8,30 @@ # replace each plugin's own config validation. # # Input: {"config": , "runtime": {"id", "version"}} -# Entrypoints: processed (config + defaults), errors (fatal), warnings. +# Rules read by the Go layer: processed (config + defaults), errors (fatal), warnings. package opa.config +import data.opa.config.util + _default_decision := "/system/main" _default_authorization_decision := "/system/authz/allow" # METADATA -# entrypoint: true # description: | # The user config with every _patches fragment merged over it. A fragment # wins where it overlaps, so enforced values (labels id/version) always apply, # while defaults are contributed only when the option is absent. processed := object.union_n(array.concat([input.config], [patch | some patch in _patches])) -_patches contains {"default_decision": _default_decision} if _absent_or_null("default_decision") +_patches contains {"default_decision": _default_decision} if util.absent(["default_decision"]) _patches contains {"default_authorization_decision": _default_authorization_decision} if { - _absent_or_null("default_authorization_decision") + util.absent(["default_authorization_decision"]) } _patches contains {"labels": {"id": input.runtime.id, "version": input.runtime.version}} -# _absent_or_null is true when a config field is missing or explicitly null. In -# both cases the default is injected, matching the pre-Rego behavior where a nil -# pointer was treated as unset. -_absent_or_null(field) if not input.config[field] - -_absent_or_null(field) if input.config[field] == null - errors contains msg if { some field in {"default_decision", "default_authorization_decision"} value := input.config[field] diff --git a/v1/plugins/server/decoding/config.go b/v1/plugins/server/decoding/config.go index 74e508817b..5a1b7bcd81 100644 --- a/v1/plugins/server/decoding/config.go +++ b/v1/plugins/server/decoding/config.go @@ -17,16 +17,26 @@ package decoding import ( - "errors" + "context" + _ "embed" - "github.com/open-policy-agent/opa/v1/util" + "github.com/open-policy-agent/opa/internal/configpolicy" + "github.com/open-policy-agent/opa/v1/config" ) -var ( - defaultMaxRequestLength = int64(268435456) // 256 MB - defaultGzipMaxContentLength = int64(536870912) // 512 MB +//go:embed validate.rego +var validationModule string + +var validationPolicy = configpolicy.New( + "opa/config/server/decoding/validate.rego", + validationModule, + "data.opa.config.server.decoding = x", ) +func init() { + config.RegisterConfigSpec(config.SpecsFromStruct[Config]("server", "decoding")...) +} + // Config represents the configuration for the Server.Decoding settings type Config struct { MaxLength *int64 `json:"max_length,omitempty"` // maximum request size that will be read, regardless of compression. @@ -56,47 +66,15 @@ func (b *ConfigBuilder) WithBytes(config []byte) *ConfigBuilder { // Parse returns a valid Config object with defaults injected. func (b *ConfigBuilder) Parse() (*Config, error) { - if b.raw == nil { - defaultConfig := &Config{ - MaxLength: &defaultMaxRequestLength, - Gzip: &Gzip{ - MaxLength: &defaultGzipMaxContentLength, - }, - } - return defaultConfig, nil - } + return b.ParseWithContext(context.Background()) +} +// ParseWithContext returns a valid Config object with defaults injected, using +// ctx to evaluate the validation policy. +func (b *ConfigBuilder) ParseWithContext(ctx context.Context) (*Config, error) { var result Config - - if err := util.Unmarshal(b.raw, &result); err != nil { + if _, err := configpolicy.EvalConfigInto(ctx, validationPolicy, b.raw, &result); err != nil { return nil, err } - - return &result, result.validateAndInjectDefaults() -} - -// validateAndInjectDefaults populates defaults if the fields are nil, then -// validates the config values. -func (c *Config) validateAndInjectDefaults() error { - if c.MaxLength == nil { - c.MaxLength = &defaultMaxRequestLength - } - - if c.Gzip == nil { - c.Gzip = &Gzip{ - MaxLength: &defaultGzipMaxContentLength, - } - } - if c.Gzip.MaxLength == nil { - c.Gzip.MaxLength = &defaultGzipMaxContentLength - } - - if *c.MaxLength <= 0 { - return errors.New("invalid value for server.decoding.max_length field, should be a positive number") - } - if *c.Gzip.MaxLength <= 0 { - return errors.New("invalid value for server.decoding.gzip.max_length field, should be a positive number") - } - - return nil + return &result, nil } diff --git a/v1/plugins/server/decoding/config_test.go b/v1/plugins/server/decoding/config_test.go index f8751092f3..a1da7855db 100644 --- a/v1/plugins/server/decoding/config_test.go +++ b/v1/plugins/server/decoding/config_test.go @@ -2,7 +2,11 @@ package decoding import ( "fmt" + "slices" + "strings" "testing" + + "github.com/open-policy-agent/opa/v1/config" ) func TestConfigValidation(t *testing.T) { @@ -58,6 +62,14 @@ func TestConfigValidation(t *testing.T) { input: `{"max_length": 42, "gzip":{"max_length": 42}}`, wantErr: false, }, + { + input: `{"gzip": [1, 2, 3]}`, + wantErr: true, + }, + { + input: `{"gzip": "nope"}`, + wantErr: true, + }, } for i, test := range tests { @@ -106,3 +118,88 @@ func TestConfigValue(t *testing.T) { }) } } + +// TestConfigRejectsWrongShapedOptions covers options the validation policy has to +// reject itself. Defaults are merged over the raw config, so an option that +// should hold an object would otherwise be silently replaced by the defaults +// instead of reaching the Go unmarshal that used to report the type error. +func TestConfigRejectsWrongShapedOptions(t *testing.T) { + tests := []struct { + input string + wantErr string + }{ + { + input: `{"gzip": [1, 2, 3]}`, + wantErr: "invalid value for server.decoding.gzip field, should be an object", + }, + { + input: `{"gzip": "nope"}`, + wantErr: "invalid value for server.decoding.gzip field, should be an object", + }, + { + input: `{"max_length": true}`, + wantErr: "invalid value for server.decoding.max_length field, should be a positive number", + }, + { + input: `{"max_length": "foobar"}`, + wantErr: "invalid value for server.decoding.max_length field, should be a positive number", + }, + { + input: `[1, 2, 3]`, + wantErr: "config must be an object", + }, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + _, err := NewConfigBuilder().WithBytes([]byte(test.input)).Parse() + if err == nil { + t.Fatalf("expected error containing %q, got none", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("expected error containing %q, got %q", test.wantErr, err.Error()) + } + }) + } +} + +// TestConfigNoWarningsForKnownDecodingOptions verifies the server.decoding spec +// registered by this package lets config.ParseConfig recognize the section's +// options. Without the registration the section is treated as open, so this also +// guards against the unknown-option warning below silently going missing. +func TestConfigNoWarningsForKnownDecodingOptions(t *testing.T) { + raw := []byte(`{"server": {"decoding": {"max_length": 5, "gzip": {"max_length": 42}}}}`) + conf, err := config.ParseConfig(raw, "id") + if err != nil { + t.Fatal(err) + } + if len(conf.Warnings) != 0 { + t.Fatalf("expected no warnings, got %v", conf.Warnings) + } +} + +func TestConfigWarnsOnUnknownDecodingOption(t *testing.T) { + for _, tc := range []struct { + raw string + want string + }{ + { + raw: `{"server": {"decoding": {"typo": 5}}}`, + want: `unknown configuration option "server.decoding.typo" encountered`, + }, + { + raw: `{"server": {"decoding": {"gzip": {"typo": 5}}}}`, + want: `unknown configuration option "server.decoding.gzip.typo" encountered`, + }, + } { + t.Run(tc.raw, func(t *testing.T) { + conf, err := config.ParseConfig([]byte(tc.raw), "id") + if err != nil { + t.Fatal(err) + } + if !slices.Contains(conf.Warnings, tc.want) { + t.Fatalf("expected warning %q, got %v", tc.want, conf.Warnings) + } + }) + } +} diff --git a/v1/plugins/server/decoding/validate.rego b/v1/plugins/server/decoding/validate.rego new file mode 100644 index 0000000000..20e953859a --- /dev/null +++ b/v1/plugins/server/decoding/validate.rego @@ -0,0 +1,37 @@ +# METADATA +# description: | +# Injects defaults and validates the server.decoding configuration (request +# size limits and gzip decompression). Evaluated by the decoding config +# builder. The policy rejects options of the wrong shape; the remaining field +# type validation is handled by unmarshaling into the Go struct. +# +# Input: {"config": } +# Rules read by the Go layer: processed (config + defaults), errors (fatal). +package opa.config.server.decoding + +import data.opa.config.util + +# Defaults mirror decoding/config.go. +_default_max_length := 268435456 # 256 MB + +_default_gzip_max_length := 536870912 # 512 MB + +# METADATA +# description: the config with decoding defaults injected for absent options. +processed := object.union_n(array.concat([input.config], [patch | some patch in _patches])) + +_patches contains {"max_length": _default_max_length} if util.absent(["max_length"]) + +_patches contains {"gzip": {"max_length": _default_gzip_max_length}} if util.absent(["gzip", "max_length"]) + +errors contains "invalid value for server.decoding.max_length field, should be a positive number" if { + util.not_positive_number(["max_length"]) +} + +errors contains "invalid value for server.decoding.gzip field, should be an object" if { + util.not_object(["gzip"]) +} + +errors contains "invalid value for server.decoding.gzip.max_length field, should be a positive number" if { + util.not_positive_number(["gzip", "max_length"]) +} diff --git a/v1/plugins/server/decoding/validate_test.rego b/v1/plugins/server/decoding/validate_test.rego new file mode 100644 index 0000000000..5ebdb16712 --- /dev/null +++ b/v1/plugins/server/decoding/validate_test.rego @@ -0,0 +1,95 @@ +package opa.config.server.decoding_test + +import data.opa.config.server.decoding + +# _non_numbers are values a max_length option must be rejected for, shared by the +# top-level and gzip cases. +_non_numbers := [ + {"note": "string", "value": "foobar"}, + {"note": "boolean", "value": true}, + {"note": "array", "value": [1]}, +] + +test_injects_defaults[tc.note] if { + some tc in [ + {"note": "empty config", "config": {}}, + {"note": "gzip present", "config": {"gzip": {}}}, + {"note": "max_length null", "config": {"max_length": null}}, + {"note": "gzip.max_length null", "config": {"gzip": {"max_length": null}}}, + ] + + result := decoding.processed with input as {"config": tc.config} + result.max_length == 268435456 + result.gzip.max_length == 536870912 +} + +test_preserves_configured_values if { + raw := {"max_length": 5, "gzip": {"max_length": 42}} + result := decoding.processed with input as {"config": raw} + result.max_length == 5 + result.gzip.max_length == 42 +} + +test_preserves_unknown_keys if { + result := decoding.processed with input as {"config": {"gzip": {"random_key": 0}}} + result.gzip.random_key == 0 +} + +test_rejects_non_positive_gzip_max_length[tc.note] if { + some tc in [ + {"note": "zero", "config": {"gzip": {"max_length": 0}}}, + {"note": "negative", "config": {"gzip": {"max_length": -10}}}, + ] + + result := decoding.errors with input as {"config": tc.config} + "invalid value for server.decoding.gzip.max_length field, should be a positive number" in result +} + +test_rejects_non_positive_top_level_max_length[tc.note] if { + some tc in [ + {"note": "zero", "config": {"max_length": 0}}, + {"note": "negative", "config": {"max_length": -10}}, + ] + + result := decoding.errors with input as {"config": tc.config} + "invalid value for server.decoding.max_length field, should be a positive number" in result +} + +# A non-number max_length is rejected here rather than left to the Go unmarshal, +# so the message names the option instead of the Go field. +test_rejects_non_number_top_level_max_length[tc.note] if { + some tc in _non_numbers + + result := decoding.errors with input as {"config": {"max_length": tc.value}} + "invalid value for server.decoding.max_length field, should be a positive number" in result +} + +test_rejects_non_number_gzip_max_length[tc.note] if { + some tc in _non_numbers + + result := decoding.errors with input as {"config": {"gzip": {"max_length": tc.value}}} + "invalid value for server.decoding.gzip.max_length field, should be a positive number" in result +} + +# Without this check the gzip defaults would be merged over the bad value, +# silently replacing it with a well-formed object. +test_rejects_non_object_gzip[tc.note] if { + some tc in [ + {"note": "array", "config": {"gzip": [1, 2, 3]}}, + {"note": "string", "config": {"gzip": "nope"}}, + {"note": "number", "config": {"gzip": 7}}, + ] + + result := decoding.errors with input as {"config": tc.config} + "invalid value for server.decoding.gzip field, should be an object" in result +} + +test_valid_config_has_no_errors if { + result := decoding.errors with input as {"config": {"max_length": 42, "gzip": {"max_length": 42}}} + count(result) == 0 +} + +test_empty_config_has_no_errors if { + result := decoding.errors with input as {"config": {}} + count(result) == 0 +} diff --git a/v1/plugins/server/encoding/config.go b/v1/plugins/server/encoding/config.go index f2bb75a7e4..35599c49d2 100644 --- a/v1/plugins/server/encoding/config.go +++ b/v1/plugins/server/encoding/config.go @@ -1,14 +1,25 @@ package encoding import ( - "compress/gzip" - "errors" + "context" + _ "embed" - "github.com/open-policy-agent/opa/v1/util" + "github.com/open-policy-agent/opa/internal/configpolicy" + "github.com/open-policy-agent/opa/v1/config" ) -var defaultGzipMinLength = 1024 -var defaultGzipCompressionLevel = gzip.BestCompression +//go:embed validate.rego +var validationModule string + +var validationPolicy = configpolicy.New( + "opa/config/server/encoding/validate.rego", + validationModule, + "data.opa.config.server.encoding = x", +) + +func init() { + config.RegisterConfigSpec(config.SpecsFromStruct[Config]("server", "encoding")...) +} // Config represents the configuration for the Server.Encoding settings type Config struct { @@ -39,53 +50,15 @@ func (b *ConfigBuilder) WithBytes(config []byte) *ConfigBuilder { // Parse returns a valid Config object with defaults injected. func (b *ConfigBuilder) Parse() (*Config, error) { - if b.raw == nil { - defaultConfig := &Config{ - Gzip: &Gzip{ - MinLength: &defaultGzipMinLength, - CompressionLevel: &defaultGzipCompressionLevel, - }, - } - return defaultConfig, nil - } + return b.ParseWithContext(context.Background()) +} +// ParseWithContext returns a valid Config object with defaults injected, using +// ctx to evaluate the validation policy. +func (b *ConfigBuilder) ParseWithContext(ctx context.Context) (*Config, error) { var result Config - - if err := util.Unmarshal(b.raw, &result); err != nil { + if _, err := configpolicy.EvalConfigInto(ctx, validationPolicy, b.raw, &result); err != nil { return nil, err } - - return &result, result.validateAndInjectDefaults() -} - -func (c *Config) validateAndInjectDefaults() error { - if c.Gzip == nil { - c.Gzip = &Gzip{ - MinLength: &defaultGzipMinLength, - CompressionLevel: &defaultGzipCompressionLevel, - } - } - if c.Gzip.MinLength == nil { - c.Gzip.MinLength = &defaultGzipMinLength - } - - if c.Gzip.CompressionLevel == nil { - c.Gzip.CompressionLevel = &defaultGzipCompressionLevel - } - - if *c.Gzip.MinLength <= 0 { - return errors.New("invalid value for server.encoding.gzip.min_length field, should be a positive number") - } - - acceptedCompressionLevels := map[int]bool{ - gzip.NoCompression: true, - gzip.BestSpeed: true, - gzip.BestCompression: true, - } - _, compressionLevelAccepted := acceptedCompressionLevels[*c.Gzip.CompressionLevel] - if !compressionLevelAccepted { - return errors.New("invalid value for server.encoding.gzip.compression_level field, accepted values are 0, 1 or 9") - } - - return nil + return &result, nil } diff --git a/v1/plugins/server/encoding/config_test.go b/v1/plugins/server/encoding/config_test.go index d8aa7482a3..307f6bbb42 100644 --- a/v1/plugins/server/encoding/config_test.go +++ b/v1/plugins/server/encoding/config_test.go @@ -2,7 +2,11 @@ package encoding import ( "fmt" + "slices" + "strings" "testing" + + "github.com/open-policy-agent/opa/v1/config" ) func TestConfigValidation(t *testing.T) { @@ -58,6 +62,14 @@ func TestConfigValidation(t *testing.T) { input: `{"gzip":{"min_length": 42, "compression_level": 9}}`, wantErr: false, }, + { + input: `{"gzip": [1, 2, 3]}`, + wantErr: true, + }, + { + input: `{"gzip": "nope"}`, + wantErr: true, + }, } for i, test := range tests { @@ -103,3 +115,88 @@ func TestConfigValue(t *testing.T) { }) } } + +// TestConfigRejectsWrongShapedOptions covers options the validation policy has to +// reject itself. Defaults are merged over the raw config, so an option that +// should hold an object would otherwise be silently replaced by the defaults +// instead of reaching the Go unmarshal that used to report the type error. +func TestConfigRejectsWrongShapedOptions(t *testing.T) { + tests := []struct { + input string + wantErr string + }{ + { + input: `{"gzip": [1, 2, 3]}`, + wantErr: "invalid value for server.encoding.gzip field, should be an object", + }, + { + input: `{"gzip": 7}`, + wantErr: "invalid value for server.encoding.gzip field, should be an object", + }, + { + input: `{"gzip": {"min_length": true}}`, + wantErr: "invalid value for server.encoding.gzip.min_length field, should be a positive number", + }, + { + input: `{"gzip": {"min_length": "foobar"}}`, + wantErr: "invalid value for server.encoding.gzip.min_length field, should be a positive number", + }, + { + input: `[1, 2, 3]`, + wantErr: "config must be an object", + }, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + _, err := NewConfigBuilder().WithBytes([]byte(test.input)).Parse() + if err == nil { + t.Fatalf("expected error containing %q, got none", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("expected error containing %q, got %q", test.wantErr, err.Error()) + } + }) + } +} + +// TestConfigNoWarningsForKnownEncodingOptions verifies the server.encoding spec +// registered by this package lets config.ParseConfig recognize the section's +// options. Without the registration the section is treated as open, so this also +// guards against the unknown-option warning below silently going missing. +func TestConfigNoWarningsForKnownEncodingOptions(t *testing.T) { + raw := []byte(`{"server": {"encoding": {"gzip": {"min_length": 42, "compression_level": 1}}}}`) + conf, err := config.ParseConfig(raw, "id") + if err != nil { + t.Fatal(err) + } + if len(conf.Warnings) != 0 { + t.Fatalf("expected no warnings, got %v", conf.Warnings) + } +} + +func TestConfigWarnsOnUnknownEncodingOption(t *testing.T) { + for _, tc := range []struct { + raw string + want string + }{ + { + raw: `{"server": {"encoding": {"typo": 5}}}`, + want: `unknown configuration option "server.encoding.typo" encountered`, + }, + { + raw: `{"server": {"encoding": {"gzip": {"typo": 5}}}}`, + want: `unknown configuration option "server.encoding.gzip.typo" encountered`, + }, + } { + t.Run(tc.raw, func(t *testing.T) { + conf, err := config.ParseConfig([]byte(tc.raw), "id") + if err != nil { + t.Fatal(err) + } + if !slices.Contains(conf.Warnings, tc.want) { + t.Fatalf("expected warning %q, got %v", tc.want, conf.Warnings) + } + }) + } +} diff --git a/v1/plugins/server/encoding/validate.rego b/v1/plugins/server/encoding/validate.rego new file mode 100644 index 0000000000..85e616112c --- /dev/null +++ b/v1/plugins/server/encoding/validate.rego @@ -0,0 +1,45 @@ +# METADATA +# description: | +# Injects defaults and validates the server.encoding configuration (gzip +# response compression). Evaluated by the encoding config builder. The policy +# rejects options of the wrong shape; the remaining field type validation is +# handled by unmarshaling into the Go struct. +# +# Input: {"config": } +# Rules read by the Go layer: processed (config + defaults), errors (fatal). +package opa.config.server.encoding + +import data.opa.config.util + +# Defaults mirror encoding/config.go. +_default_min_length := 1024 + +_default_compression_level := 9 + +# _accepted_compression_levels mirrors gzip.NoCompression, gzip.BestSpeed and +# gzip.BestCompression. +_accepted_compression_levels := {0, 1, 9} + +# METADATA +# description: the config with gzip encoding defaults injected for absent options. +processed := object.union_n(array.concat([input.config], [patch | some patch in _patches])) + +_patches contains {"gzip": {"min_length": _default_min_length}} if util.absent(["gzip", "min_length"]) + +_patches contains {"gzip": {"compression_level": _default_compression_level}} if { + util.absent(["gzip", "compression_level"]) +} + +errors contains "invalid value for server.encoding.gzip field, should be an object" if { + util.not_object(["gzip"]) +} + +errors contains "invalid value for server.encoding.gzip.min_length field, should be a positive number" if { + util.not_positive_number(["gzip", "min_length"]) +} + +errors contains "invalid value for server.encoding.gzip.compression_level field, accepted values are 0, 1 or 9" if { + value := util.value(["gzip", "compression_level"]) + value != null + not value in _accepted_compression_levels +} diff --git a/v1/plugins/server/encoding/validate_test.rego b/v1/plugins/server/encoding/validate_test.rego new file mode 100644 index 0000000000..0106e10cad --- /dev/null +++ b/v1/plugins/server/encoding/validate_test.rego @@ -0,0 +1,91 @@ +package opa.config.server.encoding_test + +import data.opa.config.server.encoding + +test_injects_defaults[tc.note] if { + some tc in [ + {"note": "empty config", "config": {}}, + {"note": "gzip present", "config": {"gzip": {}}}, + {"note": "min_length null", "config": {"gzip": {"min_length": null}}}, + {"note": "compression_level null", "config": {"gzip": {"compression_level": null}}}, + ] + + result := encoding.processed with input as {"config": tc.config} + result.gzip.min_length == 1024 + result.gzip.compression_level == 9 +} + +test_preserves_configured_values if { + raw := {"gzip": {"min_length": 42, "compression_level": 1}} + result := encoding.processed with input as {"config": raw} + result.gzip.min_length == 42 + result.gzip.compression_level == 1 +} + +test_preserves_unknown_keys if { + result := encoding.processed with input as {"config": {"gzip": {"random_key": 0}}} + result.gzip.random_key == 0 +} + +test_rejects_non_positive_min_length[tc.note] if { + some tc in [ + {"note": "zero", "min_length": 0}, + {"note": "negative", "min_length": -10}, + ] + + result := encoding.errors with input as {"config": {"gzip": {"min_length": tc.min_length}}} + "invalid value for server.encoding.gzip.min_length field, should be a positive number" in result +} + +# A non-number min_length is rejected here rather than left to the Go unmarshal, +# so the message names the option instead of the Go field. +test_rejects_non_number_min_length[tc.note] if { + some tc in [ + {"note": "string", "min_length": "foobar"}, + {"note": "boolean", "min_length": true}, + {"note": "array", "min_length": [1]}, + ] + + result := encoding.errors with input as {"config": {"gzip": {"min_length": tc.min_length}}} + "invalid value for server.encoding.gzip.min_length field, should be a positive number" in result +} + +# Without this check the gzip defaults would be merged over the bad value, +# silently replacing it with a well-formed object. +test_rejects_non_object_gzip[tc.note] if { + some tc in [ + {"note": "array", "config": {"gzip": [1, 2, 3]}}, + {"note": "string", "config": {"gzip": "nope"}}, + {"note": "number", "config": {"gzip": 7}}, + ] + + result := encoding.errors with input as {"config": tc.config} + "invalid value for server.encoding.gzip field, should be an object" in result +} + +test_rejects_unaccepted_compression_level[tc.note] if { + some tc in [ + {"note": "out of range", "compression_level": 13}, + {"note": "negative", "compression_level": -1}, + {"note": "string", "compression_level": "9"}, + ] + + result := encoding.errors with input as {"config": {"gzip": {"compression_level": tc.compression_level}}} + "invalid value for server.encoding.gzip.compression_level field, accepted values are 0, 1 or 9" in result +} + +test_accepts_valid_compression_levels[tc.note] if { + some tc in [ + {"note": "none", "compression_level": 0}, + {"note": "best speed", "compression_level": 1}, + {"note": "best compression", "compression_level": 9}, + ] + + result := encoding.errors with input as {"config": {"gzip": {"compression_level": tc.compression_level}}} + count(result) == 0 +} + +test_empty_config_has_no_errors if { + result := encoding.errors with input as {"config": {}} + count(result) == 0 +} diff --git a/v1/plugins/server/metrics/config.go b/v1/plugins/server/metrics/config.go index 0d0802e9e6..70af06e444 100644 --- a/v1/plugins/server/metrics/config.go +++ b/v1/plugins/server/metrics/config.go @@ -67,8 +67,14 @@ func (b *ConfigBuilder) WithBytes(config []byte) *ConfigBuilder { // Parse returns a valid Config object with defaults injected. func (b *ConfigBuilder) Parse() (*Config, error) { + return b.ParseWithContext(context.Background()) +} + +// ParseWithContext returns a valid Config object with defaults injected, using +// ctx to evaluate the validation policy. +func (b *ConfigBuilder) ParseWithContext(ctx context.Context) (*Config, error) { var result Config - if _, err := configpolicy.EvalConfigInto(context.TODO(), validationPolicy, b.raw, &result); err != nil { + if _, err := configpolicy.EvalConfigInto(ctx, validationPolicy, b.raw, &result); err != nil { return nil, err } return &result, nil diff --git a/v1/plugins/server/metrics/config_test.go b/v1/plugins/server/metrics/config_test.go index c776d22adc..d35fa6b62b 100644 --- a/v1/plugins/server/metrics/config_test.go +++ b/v1/plugins/server/metrics/config_test.go @@ -2,7 +2,11 @@ package metrics import ( "fmt" + "slices" + "strings" "testing" + + "github.com/open-policy-agent/opa/v1/config" ) func TestConfigValidation(t *testing.T) { @@ -59,6 +63,14 @@ func TestConfigValidation(t *testing.T) { input: `{"prom": {"http_request_duration_seconds": {"random_key": 0}}}`, wantErr: false, }, + { + input: `{"prom": [1, 2, 3]}`, + wantErr: true, + }, + { + input: `{"prom": {"http_request_duration_seconds": [1]}}`, + wantErr: true, + }, } for i, test := range tests { @@ -127,3 +139,88 @@ func valuesAreEqual(a []float64, b []float64) bool { return true } + +// TestConfigRejectsWrongShapedOptions covers options the validation policy has to +// reject itself. Defaults are merged over the raw config, so an option that +// should hold an object would otherwise be silently replaced by the defaults +// instead of reaching the Go unmarshal that used to report the type error. +func TestConfigRejectsWrongShapedOptions(t *testing.T) { + tests := []struct { + input string + wantErr string + }{ + { + input: `{"prom": [1, 2, 3]}`, + wantErr: "invalid value for server.metrics.prom field, should be an object", + }, + { + input: `{"prom": "nope"}`, + wantErr: "invalid value for server.metrics.prom field, should be an object", + }, + { + input: `{"prom": {"http_request_duration_seconds": [1]}}`, + wantErr: "invalid value for server.metrics.prom.http_request_duration_seconds field, should be an object", + }, + { + input: `{"prom": {"http_request_duration_seconds": {"buckets": ["a"]}}}`, + wantErr: "buckets field, should be an array of numbers", + }, + { + input: `[1, 2, 3]`, + wantErr: "config must be an object", + }, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + _, err := NewConfigBuilder().WithBytes([]byte(test.input)).Parse() + if err == nil { + t.Fatalf("expected error containing %q, got none", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("expected error containing %q, got %q", test.wantErr, err.Error()) + } + }) + } +} + +// TestConfigNoWarningsForKnownMetricsOptions verifies the server.metrics spec +// registered by this package lets config.ParseConfig recognize the section's +// options. Without the registration the section is treated as open, so this also +// guards against the unknown-option warning below silently going missing. +func TestConfigNoWarningsForKnownMetricsOptions(t *testing.T) { + raw := []byte(`{"server": {"metrics": {"prom": {"http_request_duration_seconds": {"buckets": [0.1, 1]}}}}}`) + conf, err := config.ParseConfig(raw, "id") + if err != nil { + t.Fatal(err) + } + if len(conf.Warnings) != 0 { + t.Fatalf("expected no warnings, got %v", conf.Warnings) + } +} + +func TestConfigWarnsOnUnknownMetricsOption(t *testing.T) { + for _, tc := range []struct { + raw string + want string + }{ + { + raw: `{"server": {"metrics": {"typo": 5}}}`, + want: `unknown configuration option "server.metrics.typo" encountered`, + }, + { + raw: `{"server": {"metrics": {"prom": {"typo": 5}}}}`, + want: `unknown configuration option "server.metrics.prom.typo" encountered`, + }, + } { + t.Run(tc.raw, func(t *testing.T) { + conf, err := config.ParseConfig([]byte(tc.raw), "id") + if err != nil { + t.Fatal(err) + } + if !slices.Contains(conf.Warnings, tc.want) { + t.Fatalf("expected warning %q, got %v", tc.want, conf.Warnings) + } + }) + } +} diff --git a/v1/plugins/server/metrics/validate.rego b/v1/plugins/server/metrics/validate.rego index 8e31bffc96..ce0f8da57e 100644 --- a/v1/plugins/server/metrics/validate.rego +++ b/v1/plugins/server/metrics/validate.rego @@ -1,26 +1,57 @@ # METADATA # description: | -# Injects defaults for the server.metrics configuration (Prometheus HTTP -# request duration histogram buckets). Evaluated by the metrics config -# builder. Value/type validation of the buckets is handled by unmarshaling -# into the Go struct. +# Injects defaults and validates the server.metrics configuration (Prometheus +# HTTP request duration histogram buckets). Evaluated by the metrics config +# builder. The policy rejects options of the wrong shape; the remaining field +# type validation is handled by unmarshaling into the Go struct. # # Input: {"config": } -# Entrypoint: processed (config + defaults). +# Rules read by the Go layer: processed (config + defaults), errors (fatal). package opa.config.server.metrics +import data.opa.config.util + # _default_buckets mirrors defaultHTTPRequestBuckets in config.go. _default_buckets := [1e-6, 5e-6, 1e-5, 5e-5, 1e-4, 5e-4, 1e-3, 0.01, 0.1, 1] # METADATA -# entrypoint: true # description: the config with the default histogram buckets injected when absent. processed := object.union_n(array.concat([input.config], [patch | some patch in _patches])) _patches contains {"prom": {"http_request_duration_seconds": {"buckets": _default_buckets}}} if { - _absent(["prom", "http_request_duration_seconds", "buckets"]) + util.absent(["prom", "http_request_duration_seconds", "buckets"]) } -# _absent is true when the option at path is missing or explicitly null, matching -# the pre-Rego behavior where a nil slice was replaced with defaults. -_absent(path) if object.get(input.config, path, null) == null +errors contains "invalid value for server.metrics.prom field, should be an object" if { + util.not_object(["prom"]) +} + +errors contains "invalid value for server.metrics.prom.http_request_duration_seconds field, should be an object" if { + util.not_object(["prom", "http_request_duration_seconds"]) +} + +# The message is a rule of its own because naming the option in the rule head would +# put the line over the length limit. +errors contains _buckets_msg if { + _not_number_array(["prom", "http_request_duration_seconds", "buckets"]) +} + +_buckets_field := "server.metrics.prom.http_request_duration_seconds.buckets" + +_buckets_msg := $`invalid value for {_buckets_field} field, should be an array of numbers` + +# _not_number_array rejects a present-but-wrong-shaped buckets option here rather +# than leaving it to the Go unmarshal, so the message names the config option +# instead of the Go struct field. +_not_number_array(path) if { + value := util.value(path) + value != null + not _number_array(value) +} + +_number_array(value) if { + is_array(value) + every item in value { + is_number(item) + } +} diff --git a/v1/plugins/server/metrics/validate_test.rego b/v1/plugins/server/metrics/validate_test.rego index 60753fb741..fef841d96f 100644 --- a/v1/plugins/server/metrics/validate_test.rego +++ b/v1/plugins/server/metrics/validate_test.rego @@ -4,6 +4,10 @@ import data.opa.config.server.metrics _default_buckets := [1e-6, 5e-6, 1e-5, 5e-5, 1e-4, 5e-4, 1e-3, 0.01, 0.1, 1] +_buckets_field := "server.metrics.prom.http_request_duration_seconds.buckets" + +_buckets_msg := $`invalid value for {_buckets_field} field, should be an array of numbers` + test_injects_default_buckets[tc.note] if { some tc in [ {"note": "empty config", "config": {}}, @@ -31,3 +35,55 @@ test_preserves_unknown_keys if { result := metrics.processed with input as {"config": {"prom": {"random_key": 0}}} result.prom.random_key == 0 } + +# Without these checks the bucket defaults would be merged over the bad value, +# silently replacing it with a well-formed object. +test_rejects_non_object_prom[tc.note] if { + some tc in [ + {"note": "array", "config": {"prom": [1, 2, 3]}}, + {"note": "string", "config": {"prom": "nope"}}, + {"note": "number", "config": {"prom": 7}}, + ] + + result := metrics.errors with input as {"config": tc.config} + "invalid value for server.metrics.prom field, should be an object" in result +} + +test_rejects_non_object_http_request_duration_seconds[tc.note] if { + some tc in [ + {"note": "array", "value": [1]}, + {"note": "string", "value": "x"}, + ] + + result := metrics.errors with input as {"config": {"prom": {"http_request_duration_seconds": tc.value}}} + "invalid value for server.metrics.prom.http_request_duration_seconds field, should be an object" in result +} + +# A non-numeric buckets value is rejected here rather than left to the Go +# unmarshal, so the message names the option instead of the Go field. +test_rejects_non_number_array_buckets[tc.note] if { + some tc in [ + {"note": "string", "buckets": "x"}, + {"note": "object", "buckets": {}}, + {"note": "array of strings", "buckets": ["a"]}, + {"note": "mixed array", "buckets": [1, "a"]}, + ] + + config := {"prom": {"http_request_duration_seconds": {"buckets": tc.buckets}}} + result := metrics.errors with input as {"config": config} + _buckets_msg in result +} + +test_valid_config_has_no_errors[tc.note] if { + some tc in [ + {"note": "empty config", "config": {}}, + {"note": "prom present", "config": {"prom": {}}}, + {"note": "section present", "config": {"prom": {"http_request_duration_seconds": {}}}}, + {"note": "custom buckets", "config": {"prom": {"http_request_duration_seconds": {"buckets": [0.1, 4]}}}}, + {"note": "empty buckets", "config": {"prom": {"http_request_duration_seconds": {"buckets": []}}}}, + {"note": "buckets null", "config": {"prom": {"http_request_duration_seconds": {"buckets": null}}}}, + ] + + result := metrics.errors with input as {"config": tc.config} + count(result) == 0 +} diff --git a/v1/runtime/runtime.go b/v1/runtime/runtime.go index b389decdd8..3054f5e801 100644 --- a/v1/runtime/runtime.go +++ b/v1/runtime/runtime.go @@ -457,7 +457,7 @@ func NewRuntime(ctx context.Context, params Params) (*Runtime, error) { params.Router = http.NewServeMux() } - metricsConfig, parseConfigErr := extractMetricsConfig(config, params) + metricsConfig, parseConfigErr := extractMetricsConfig(ctx, config, params) if parseConfigErr != nil { return nil, parseConfigErr } @@ -589,7 +589,7 @@ func NewRuntime(ctx context.Context, params Params) (*Runtime, error) { } // extractMetricsConfig returns the configuration for server metrics and parsing errors if any -func extractMetricsConfig(config []byte, params Params) (*metrics_config.Config, error) { +func extractMetricsConfig(ctx context.Context, config []byte, params Params) (*metrics_config.Config, error) { opaParsedConfig, opaParsedConfigErr := opa_config.ParseConfig(config, params.ID) if opaParsedConfigErr != nil { return nil, opaParsedConfigErr @@ -601,7 +601,7 @@ func extractMetricsConfig(config []byte, params Params) (*metrics_config.Config, } configBuilder := metrics_config.NewConfigBuilder() - metricsParsedConfig, metricsParsedConfigErr := configBuilder.WithBytes(serverMetricsData).Parse() + metricsParsedConfig, metricsParsedConfigErr := configBuilder.WithBytes(serverMetricsData).ParseWithContext(ctx) if metricsParsedConfigErr != nil { return nil, fmt.Errorf("server metrics configuration parse error: %w", metricsParsedConfigErr) } diff --git a/v1/server/server.go b/v1/server/server.go index 82a8a32418..12ef8edee5 100644 --- a/v1/server/server.go +++ b/v1/server/server.go @@ -232,13 +232,13 @@ func (s *Server) Init(ctx context.Context) (*Server, error) { s.Handler = s.initHandlerAuthn(s.Handler) // compression handler - s.Handler, err = s.initHandlerCompression(s.Handler) + s.Handler, err = s.initHandlerCompression(ctx, s.Handler) if err != nil { return nil, err } s.DiagnosticHandler = s.initHandlerAuthn(s.DiagnosticHandler) - s.Handler, err = s.initHandlerDecodingLimits(s.Handler) + s.Handler, err = s.initHandlerDecodingLimits(ctx, s.Handler) if err != nil { return nil, err } @@ -812,13 +812,13 @@ func (s *Server) initHandlerAuthz(handler http.Handler) http.Handler { // Enforces request body size limits on incoming requests. For gzipped requests, // it passes the size limit down the body-reading method via the request // context. -func (s *Server) initHandlerDecodingLimits(handler http.Handler) (http.Handler, error) { +func (s *Server) initHandlerDecodingLimits(ctx context.Context, handler http.Handler) (http.Handler, error) { cfg := s.manager.GetConfig() var decodingRawConfig []byte if cfg.Server != nil { decodingRawConfig = []byte(cfg.Server.Decoding) } - decodingConfig, err := serverDecodingPlugin.NewConfigBuilder().WithBytes(decodingRawConfig).Parse() + decodingConfig, err := serverDecodingPlugin.NewConfigBuilder().WithBytes(decodingRawConfig).ParseWithContext(ctx) if err != nil { return nil, err } @@ -827,13 +827,13 @@ func (s *Server) initHandlerDecodingLimits(handler http.Handler) (http.Handler, return decodingHandler, nil } -func (s *Server) initHandlerCompression(handler http.Handler) (http.Handler, error) { +func (s *Server) initHandlerCompression(ctx context.Context, handler http.Handler) (http.Handler, error) { cfg := s.manager.GetConfig() var encodingRawConfig []byte if cfg.Server != nil { encodingRawConfig = []byte(cfg.Server.Encoding) } - encodingConfig, err := serverEncodingPlugin.NewConfigBuilder().WithBytes(encodingRawConfig).Parse() + encodingConfig, err := serverEncodingPlugin.NewConfigBuilder().WithBytes(encodingRawConfig).ParseWithContext(ctx) if err != nil { return nil, err }