Files
releases/v1/config/validate.go
T
Sebastian Spaink 8e2f1807ac config: validate configuration with Rego and warn on unknown options (#8891)
Part of #2745

Like most of his ideas, @anderseknert's suggestion to use Rego to
replace the `validateAndInjectDefaults` functions throughout the
codebase is another winner.

This PR starts the migration by replacing the top-level
`validateAndInjectDefaults` in `v1/config/config.go` with an embedded
policy, `validate.rego`. The policy injects the top-level defaults
(`default_decision`, `default_authorization_decision`, `labels`) and
reports unrecognized configuration options, so a typo such as
`decision_log` instead of `decision_logs` is logged as a warning at
startup rather than silently ignored.

It's evaluated in `ParseConfig` using the low-level `ast`/`topdown`
packages rather than the top-level `rego` package. This keeps `config`
off the heavy `rego → bundle → …` dependency web (which would otherwise
create import cycles as more packages' tests reach `config`), and we
don't need any of the `rego` package's conveniences here — it's one
module compiled once and a single query. The Rego unit tests run in CI
via `build/run-rego-tests.sh` (and locally with `make rego-test`).

This sets the foundation for the other plugin
`validateAndInjectDefaults` functions to migrate to Rego as well; where
the logic isn't too complicated it should be a fairly easy replacement.
At the moment all known keys live in `validate.rego` under `_specs` to
support the "warn on unrecognized options" check, but the
plugin-specific entries can move closer to each plugin as it migrates.
It would also be nice for `_specs` to be auto-generated somehow in the
future.

Supporting extension of config validation with custom policies is
something I'd like to follow up with, so keeping #2745 open for now.

I also think these policies could be reusable with
[java-opa-sdk](https://github.com/open-policy-agent/java-opa-sdk) 👀

Signed-off-by: Sebastian Spaink <sebastianspaink@gmail.com>
2026-07-15 11:16:03 -05:00

147 lines
4.1 KiB
Go

// Copyright 2025 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package config
import (
"context"
_ "embed"
"errors"
"fmt"
"slices"
"strings"
"sync"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/storage/inmem"
"github.com/open-policy-agent/opa/v1/topdown"
"github.com/open-policy-agent/opa/v1/version"
)
// coreValidationModule is the embedded policy that injects OPA's config
// defaults and reports unrecognized options.
//
//go:embed validate.rego
var coreValidationModule string
const coreValidationPolicyName = "opa/config/validate.rego"
// validationQuery yields a document with `processed`, `warnings` and `errors`.
// The result is bound to x. (The topdown package is used directly rather than
// the rego package to avoid a config -> rego -> bundle import cycle.)
const validationQuery = "data.opa.config = x"
// validationQueryBody is parsed once; the query never changes.
var validationQueryBody = ast.MustParseBody(validationQuery)
// The policy is static, so it is compiled once and reused across all calls.
var (
compileOnce sync.Once
compiledPolicy *ast.Compiler
compileErr error
)
// evaluateConfigPolicy runs the validation policy against raw. It returns the
// config with defaults injected and any warnings; a non-empty set of policy
// `errors` is returned as a single error.
func evaluateConfigPolicy(ctx context.Context, raw any, id string) (map[string]any, []string, error) {
compiler, err := compileValidationPolicy()
if err != nil {
return nil, nil, err
}
inputValue, err := ast.InterfaceToValue(map[string]any{
"config": raw,
"runtime": map[string]any{
"id": id,
"version": version.Version,
},
})
if err != nil {
return nil, nil, fmt.Errorf("config validation: %w", err)
}
store := inmem.New()
txn, err := store.NewTransaction(ctx)
if err != nil {
return nil, nil, fmt.Errorf("config validation: %w", err)
}
defer store.Abort(ctx, txn)
qrs, err := topdown.NewQuery(validationQueryBody).
WithCompiler(compiler).
WithStore(store).
WithTransaction(txn).
WithInput(ast.NewTerm(inputValue)).
Run(ctx)
if err != nil {
return nil, nil, fmt.Errorf("config validation: %w", err)
}
if len(qrs) != 1 {
return nil, nil, errors.New("config validation: policy produced no result")
}
result, err := ast.JSON(qrs[0][ast.Var("x")].Value)
if err != nil {
return nil, nil, fmt.Errorf("config validation: %w", err)
}
doc, ok := result.(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("config validation: unexpected result type %T", result)
}
if errs := stringSet(doc["errors"]); len(errs) > 0 {
slices.Sort(errs)
return nil, nil, errors.New(strings.Join(errs, "; "))
}
processed, ok := doc["processed"].(map[string]any)
if !ok {
return nil, nil, errors.New("config validation: policy did not produce a processed configuration")
}
warnings := stringSet(doc["warnings"])
slices.Sort(warnings)
return processed, warnings, nil
}
// compileValidationPolicy parses and compiles the embedded core policy once.
func compileValidationPolicy() (*ast.Compiler, error) {
compileOnce.Do(func() {
popts := ast.ParserOptions{RegoVersion: ast.RegoV1}
core, err := ast.ParseModuleWithOpts(coreValidationPolicyName, coreValidationModule, popts)
if err != nil {
compileErr = fmt.Errorf("config validation: %w", err)
return
}
compiler := ast.NewCompiler()
if compiler.Compile(map[string]*ast.Module{coreValidationPolicyName: core}); compiler.Failed() {
compileErr = fmt.Errorf("config validation: %w", compiler.Errors)
return
}
compiledPolicy = compiler
})
return compiledPolicy, compileErr
}
// stringSet turns a Rego set/array result into a []string, ignoring non-string
// members and returning nil when empty.
func stringSet(v any) []string {
items, ok := v.([]any)
if !ok || len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
}