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 <sebastianspaink@gmail.com>
This commit is contained in:
Sebastian Spaink
2026-08-04 11:05:36 -05:00
committed by GitHub
parent 6682a18b12
commit 40dd2b90d2
21 changed files with 844 additions and 140 deletions
+3
View File
@@ -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:
+11 -2
View File
@@ -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"
+26 -4
View File
@@ -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": <raw>} 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
}
@@ -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)
+45
View File
@@ -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
}
+6 -9
View File
@@ -5,11 +5,13 @@
# handled by unmarshaling into the Go struct.
#
# Input: {"config": <raw metrics_export 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 != ""
}
+5 -11
View File
@@ -8,36 +8,30 @@
# replace each plugin's own config validation.
#
# Input: {"config": <raw 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]
+22 -44
View File
@@ -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
}
+97
View File
@@ -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)
}
})
}
}
+37
View File
@@ -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": <raw server.decoding 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"])
}
@@ -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
}
+23 -50
View File
@@ -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
}
+97
View File
@@ -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)
}
})
}
}
+45
View File
@@ -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": <raw server.encoding 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
}
@@ -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
}
+7 -1
View File
@@ -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
+97
View File
@@ -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)
}
})
}
}
+41 -10
View File
@@ -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": <raw server.metrics 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)
}
}
@@ -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
}
+3 -3
View File
@@ -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)
}
+6 -6
View File
@@ -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
}