Files
releases/v1/topdown/external_source_test.go
Stephan Renatus a7265038f2 ast+topdown: parametrized (prefix) external rule sources (#8881)
Let a single registered external source serve an unbounded family of
sub-references by adding an optional ParametrizedExternalRuleIndex
interface whose ParamArity(tail Ref) int the evaluator queries at lookup
time. Fixed arity is the degenerate implementation (return N); the
exact-ref path is unchanged (a source that doesn't implement the
interface has arity 0, with no forced no-op method — same optional-
interface idiom as ExternalRuleIndexCloser).

- A single registered prefix can now back an uneven-depth tree — e.g.
  data.reg.user[k] consuming one key while data.reg.pair[a][b] consumes
  two — by keying off a leading discriminator segment.

- Caching is preserved. The contract constrains the count to depend on
  the tail's *shape*, not on parameter *values*, so it's known before
  Lookup and the cache key (prefix + ground params) is still computable
  up front — no need to call Lookup before knowing the boundary.

For example, registering prefix data.foo with ParamArity(data.foo) 1 makes
data.foo[<key>].<rule> resolve <key> as a parameter, handed to Lookup
via LookupOptions.Params. One source can then serve a distinct set of
rules per key without registering each key concretely, so references
whose key only comes into existence at runtime resolve without a
recompile.

Signed-off-by: Stephan Renatus <stephan.renatus@gmail.com>
2026-07-13 19:50:02 +02:00

729 lines
21 KiB
Go

package topdown
import (
"context"
"errors"
"fmt"
"slices"
"sync/atomic"
"testing"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/metrics"
"github.com/open-policy-agent/opa/v1/storage/inmem"
)
type countingExternalSource struct {
refs []ast.Ref
rules []*ast.Rule
callCount int32
}
func (m *countingExternalSource) Init(context.Context, ast.Ref) (ast.ExternalRuleIndex, error) {
return &countingExternalIndex{rules: m.rules, callCount: &m.callCount}, nil
}
func (m *countingExternalSource) Refs() []ast.Ref {
return m.refs
}
type countingExternalIndex struct {
rules []*ast.Rule
callCount *int32
}
func (*countingExternalIndex) Opts() *ast.ExternalSourceOptions {
return nil
}
func (m *countingExternalIndex) Lookup(context.Context, ...ast.LookupOption) ([]*ast.Rule, ast.ExternalRuleIndex, error) {
atomic.AddInt32(m.callCount, 1)
return m.rules, nil, nil
}
func (m *countingExternalSource) getCallCount() int {
return int(atomic.LoadInt32(&m.callCount))
}
func setupCompiler(t *testing.T, packageRef ast.Ref, source ast.ExternalRuleSource, staticModule *ast.Module) *ast.Compiler {
t.Helper()
compiler := ast.NewCompiler()
compiler.WithExternalSource(packageRef, source)
modules := map[string]*ast.Module{}
if staticModule != nil {
modules["main.rego"] = staticModule
}
compiler.Compile(modules)
if compiler.Failed() {
t.Fatalf("Compiler failed: %v", compiler.Errors)
}
return compiler
}
func runQuery(t *testing.T, compiler *ast.Compiler, queryStr string, input *ast.Term) QueryResultSet {
t.Helper()
store := inmem.New()
ctx := t.Context()
txn, err := store.NewTransaction(ctx)
if err != nil {
t.Fatal(err)
}
defer store.Abort(ctx, txn)
m := metrics.New()
instr := NewInstrumentation(m)
query := ast.MustParseBody(queryStr)
q := NewQuery(query).
WithCompiler(compiler).
WithStore(store).
WithTransaction(txn).
WithInput(input).
WithInstrumentation(instr)
qrs, err := q.Run(ctx)
if err != nil {
t.Fatalf("Query failed: %v", err)
}
t.Logf("metrics: %v", m.All())
return qrs
}
func TestExternalSourceE2EWithInputOverride(t *testing.T) {
t.Parallel()
externalModule := ast.MustParseModule(`package authz
allowed if input.user == "alice"`)
packageRef := ast.MustParseRef("data.authz")
source := &countingExternalSource{refs: []ast.Ref{packageRef}, rules: externalModule.Rules}
staticModule := ast.MustParseModule(`package main
check if {
data.authz.allowed
data.authz.allowed with input as {"user": "bob"}
}`)
compiler := setupCompiler(t, packageRef, source, staticModule)
input := ast.MustParseTerm(`{"user": "alice"}`)
qrs := runQuery(t, compiler, "data.main.check", input)
if len(qrs) != 0 {
t.Errorf("Expected 0 results (second check with bob should fail), got %d", len(qrs))
}
if callCount := source.getCallCount(); callCount != 2 {
t.Errorf("Expected external source to be called twice (once per input), got %d calls", callCount)
}
}
func TestExternalSourceE2EWithMultipleRulesFromSamePackage(t *testing.T) {
t.Parallel()
externalModule := ast.MustParseModule(`package authz
allow if input.user == "alice"
deny if input.action == "delete"
allowed if {
allow
not deny
}`)
packageRef := ast.MustParseRef("data.authz")
source := &countingExternalSource{refs: []ast.Ref{packageRef}, rules: externalModule.Rules}
staticModule := ast.MustParseModule(`package main
check if data.authz.allowed`)
compiler := setupCompiler(t, packageRef, source, staticModule)
input := ast.MustParseTerm(`{"user": "alice", "action": "read"}`)
qrs := runQuery(t, compiler, "data.main.check", input)
if len(qrs) != 1 {
t.Errorf("Expected 1 result, got %d", len(qrs))
}
if callCount := source.getCallCount(); callCount != 1 {
t.Errorf("Expected external source to be called once (cached for same ref and input), got %d calls", callCount)
}
}
type closableExternalSource struct {
refs []ast.Ref
rules []*ast.Rule
closeCalls int32
}
func (m *closableExternalSource) Init(context.Context, ast.Ref) (ast.ExternalRuleIndex, error) {
return &closableExternalIndex{rules: m.rules, closeCalls: &m.closeCalls}, nil
}
func (m *closableExternalSource) Refs() []ast.Ref {
return m.refs
}
func (m *closableExternalSource) getCloseCalls() int {
return int(atomic.LoadInt32(&m.closeCalls))
}
type closableExternalIndex struct {
rules []*ast.Rule
closeCalls *int32
}
func (*closableExternalIndex) Opts() *ast.ExternalSourceOptions {
return nil
}
func (m *closableExternalIndex) Lookup(context.Context, ...ast.LookupOption) ([]*ast.Rule, ast.ExternalRuleIndex, error) {
return m.rules, nil, nil
}
func (m *closableExternalIndex) Close() error {
atomic.AddInt32(m.closeCalls, 1)
return nil
}
func TestExternalSourceCloseCalled(t *testing.T) {
t.Parallel()
externalModule := ast.MustParseModule(`package authz
allowed if input.user == "alice"`)
packageRef := ast.MustParseRef("data.authz")
source := &closableExternalSource{refs: []ast.Ref{packageRef}, rules: externalModule.Rules}
staticModule := ast.MustParseModule(`package main
check if data.authz.allowed`)
compiler := setupCompiler(t, packageRef, source, staticModule)
input := ast.MustParseTerm(`{"user": "alice"}`)
qrs := runQuery(t, compiler, "data.main.check", input)
if len(qrs) != 1 {
t.Errorf("Expected 1 result, got %d", len(qrs))
}
if closeCalls := source.getCloseCalls(); closeCalls != 1 {
t.Errorf("Expected Close() to be called once, got %d calls", closeCalls)
}
}
type preCompiledRulesSource struct {
refs []ast.Ref
compiledRules []*ast.Rule
}
func (s *preCompiledRulesSource) Init(context.Context, ast.Ref) (ast.ExternalRuleIndex, error) {
return &preCompiledRulesIndex{compiledRules: s.compiledRules}, nil
}
func (s *preCompiledRulesSource) Refs() []ast.Ref {
return s.refs
}
type preCompiledRulesIndex struct {
compiledRules []*ast.Rule
}
func (*preCompiledRulesIndex) Opts() *ast.ExternalSourceOptions {
// For pre-compiled rules, skip all stages except those essential for
// integrating the rules into the compiler
var skippedStages []ast.StageID
essentialStages := []ast.StageID{
ast.StageSetModuleTree,
ast.StageSetRuleTree, ast.StageBuildRuleIndices,
}
for _, stage := range ast.AllStages() {
if !slices.Contains(essentialStages, stage) {
skippedStages = append(skippedStages, stage)
}
}
return &ast.ExternalSourceOptions{
SkippedStages: skippedStages,
}
}
func (idx *preCompiledRulesIndex) Lookup(_ context.Context, _ ...ast.LookupOption) ([]*ast.Rule, ast.ExternalRuleIndex, error) {
return idx.compiledRules, nil, nil
}
func TestExternalSourceWithPreCompiledRules(t *testing.T) {
t.Parallel()
// Create and pre-compile an external module
externalModule := ast.MustParseModule(`package authz
allow if input.user == "admin"
deny if input.action == "delete"
permitted if {
allow
not deny
}`)
// Pre-compile the rules using a separate compiler
preCompiler := ast.NewCompiler()
preCompiler.Compile(map[string]*ast.Module{"authz.rego": externalModule})
if preCompiler.Failed() {
t.Fatalf("Pre-compilation failed: %v", preCompiler.Errors)
}
// Extract the pre-compiled rules
compiledRules := make([]*ast.Rule, 0, len(preCompiler.Modules))
for _, mod := range preCompiler.Modules {
compiledRules = append(compiledRules, mod.Rules...)
}
if len(compiledRules) == 0 {
t.Fatal("No compiled rules found")
}
// Create an external source that returns pre-compiled rules
packageRef := ast.MustParseRef("data.authz")
source := &preCompiledRulesSource{
refs: []ast.Ref{packageRef},
compiledRules: compiledRules,
}
// Create a static module that uses the externally-provided rules
staticModule := ast.MustParseModule(`package main
check if data.authz.permitted`)
// Set up compiler with the external source
compiler := setupCompiler(t, packageRef, source, staticModule)
t.Run("admin with read action should be allowed", func(t *testing.T) {
input := ast.MustParseTerm(`{"user": "admin", "action": "read"}`)
qrs := runQuery(t, compiler, "data.main.check", input)
if len(qrs) != 1 {
t.Errorf("Expected 1 result (allowed), got %d", len(qrs))
}
})
t.Run("admin with delete action should be denied", func(t *testing.T) {
input := ast.MustParseTerm(`{"user": "admin", "action": "delete"}`)
qrs := runQuery(t, compiler, "data.main.check", input)
if len(qrs) != 0 {
t.Errorf("Expected 0 results (denied), got %d", len(qrs))
}
})
t.Run("non-admin user should be denied", func(t *testing.T) {
input := ast.MustParseTerm(`{"user": "bob", "action": "read"}`)
qrs := runQuery(t, compiler, "data.main.check", input)
if len(qrs) != 0 {
t.Errorf("Expected 0 results (denied), got %d", len(qrs))
}
})
}
func TestExternalSourceDirectQuery(t *testing.T) {
t.Parallel()
externalModule := ast.MustParseModule(`package authz
allow if input.user == "alice"
deny if input.user == "bob"`)
packageRef := ast.MustParseRef("data.authz")
source := &countingExternalSource{refs: []ast.Ref{packageRef}, rules: externalModule.Rules}
compiler := setupCompiler(t, packageRef, source, nil)
t.Run("direct query allowed", func(t *testing.T) {
input := ast.MustParseTerm(`{"user": "alice"}`)
qrs := runQuery(t, compiler, "data.authz.allow", input)
if len(qrs) != 1 {
t.Errorf("Expected 1 result for direct external query, got %d", len(qrs))
}
})
t.Run("direct query denied", func(t *testing.T) {
input := ast.MustParseTerm(`{"user": "bob"}`)
qrs := runQuery(t, compiler, "data.authz.deny", input)
if len(qrs) != 1 {
t.Errorf("Expected 1 result for direct external query, got %d", len(qrs))
}
})
t.Run("direct query no match", func(t *testing.T) {
input := ast.MustParseTerm(`{"user": "charlie"}`)
qrs := runQuery(t, compiler, "data.authz.allow", input)
if len(qrs) != 0 {
t.Errorf("Expected 0 results, got %d", len(qrs))
}
})
}
func TestExternalSourceCompilationFailure(t *testing.T) {
t.Parallel()
// Rules with an unsafe variable should fail compilation
externalModule := ast.MustParseModule(`package authz
allow if { x }`)
packageRef := ast.MustParseRef("data.authz")
source := &countingExternalSource{refs: []ast.Ref{packageRef}, rules: externalModule.Rules}
staticModule := ast.MustParseModule(`package main
check if data.authz.allow`)
compiler := setupCompiler(t, packageRef, source, staticModule)
store := inmem.New()
ctx := t.Context()
txn, err := store.NewTransaction(ctx)
if err != nil {
t.Fatal(err)
}
defer store.Abort(ctx, txn)
query := ast.MustParseBody("data.main.check")
q := NewQuery(query).
WithCompiler(compiler).
WithStore(store).
WithTransaction(txn)
_, err = q.Run(ctx)
if err == nil {
t.Fatal("Expected compilation error from external source, got nil")
}
var errs ast.Errors
if !errors.As(err, &errs) {
t.Fatalf("Expected ast.Errors, got: %T: %v", err, err)
}
if !slices.ContainsFunc(errs, func(e *ast.Error) bool { return e.Code == ast.UnsafeVarErr }) {
t.Errorf("Expected unsafe var error, got: %v", err)
}
}
// paramExternalSource is a parametrized (prefix) external source: it is
// registered under a prefix ref (e.g. data.directory.user) with ParamArity 1, and
// ParametrizedExternalRuleIndex with arity 1, and
// synthesizes a distinct module per key on each Lookup from the parameter term.
// It stands in for a real source that serves a different set of rules per key.
type paramExternalSource struct {
refs []ast.Ref
arity int
callCount int32
keys []string
}
func (s *paramExternalSource) Refs() []ast.Ref { return s.refs }
func (s *paramExternalSource) getCallCount() int { return int(atomic.LoadInt32(&s.callCount)) }
func (s *paramExternalSource) Init(_ context.Context, ref ast.Ref) (ast.ExternalRuleIndex, error) {
return &paramExternalIndex{prefix: ref, arity: s.arity, src: s}, nil
}
type paramExternalIndex struct {
prefix ast.Ref
arity int
src *paramExternalSource
}
func (*paramExternalIndex) Opts() *ast.ExternalSourceOptions {
return &ast.ExternalSourceOptions{}
}
// ParamArity declares this source parametrized: the registered prefix is
// followed by idx.arity key segment(s) consumed as lookup parameters.
func (idx *paramExternalIndex) ParamArity(ast.Ref) int {
return idx.arity
}
func (idx *paramExternalIndex) Lookup(_ context.Context, opts ...ast.LookupOption) ([]*ast.Rule, ast.ExternalRuleIndex, error) {
atomic.AddInt32(&idx.src.callCount, 1)
o := ast.LookupOptions{}
for _, opt := range opts {
opt(&o)
}
params := o.Params()
if len(params) != idx.arity {
return nil, nil, fmt.Errorf("expected %d param(s), got %d", idx.arity, len(params))
}
key, ok := params[0].(ast.String)
if !ok {
return nil, nil, fmt.Errorf("expected string key, got %v", params[0])
}
idx.src.keys = append(idx.src.keys, string(key))
// The prefix (e.g. data.directory.user) is the Rego package of the synthesized
// module; its rules echo the key and gate on input.
pkgPath := idx.prefix.String()[len("data."):]
mod := ast.MustParseModule(fmt.Sprintf(
"package %s\nid := %q\nallow if input.account == %q",
pkgPath, string(key), string(key)))
return mod.Rules, nil, nil
}
func TestExternalSourceParametrizedDistinctKeys(t *testing.T) {
t.Parallel()
prefix := ast.MustParseRef("data.directory.user")
source := &paramExternalSource{refs: []ast.Ref{prefix}, arity: 1}
// Two distinct keys in a single evaluation must not collide.
staticModule := ast.MustParseModule(`package main
check if {
data.directory.user["123"].id == "123"
data.directory.user["456"].id == "456"
}`)
compiler := setupCompiler(t, prefix, source, staticModule)
qrs := runQuery(t, compiler, "data.main.check", nil)
if len(qrs) != 1 {
t.Fatalf("Expected 1 result, got %d", len(qrs))
}
if got := source.getCallCount(); got != 2 {
t.Errorf("Expected external source to be called twice (once per distinct key), got %d (keys=%v)", got, source.keys)
}
}
func TestExternalSourceParametrizedWrongKeyUndefined(t *testing.T) {
t.Parallel()
prefix := ast.MustParseRef("data.directory.user")
source := &paramExternalSource{refs: []ast.Ref{prefix}, arity: 1}
// The key's module echoes its own id; asking for a mismatched id is undefined.
staticModule := ast.MustParseModule(`package main
check if data.directory.user["123"].id == "999"`)
compiler := setupCompiler(t, prefix, source, staticModule)
qrs := runQuery(t, compiler, "data.main.check", nil)
if len(qrs) != 0 {
t.Errorf("Expected 0 results for mismatched id, got %d", len(qrs))
}
}
func TestExternalSourceParametrizedInputGate(t *testing.T) {
t.Parallel()
prefix := ast.MustParseRef("data.directory.user")
source := &paramExternalSource{refs: []ast.Ref{prefix}, arity: 1}
staticModule := ast.MustParseModule(`package main
check if data.directory.user["777"].allow`)
compiler := setupCompiler(t, prefix, source, staticModule)
t.Run("input matches key", func(t *testing.T) {
input := ast.MustParseTerm(`{"account": "777"}`)
qrs := runQuery(t, compiler, "data.main.check", input)
if len(qrs) != 1 {
t.Errorf("Expected 1 result, got %d", len(qrs))
}
})
t.Run("input does not match key", func(t *testing.T) {
input := ast.MustParseTerm(`{"account": "000"}`)
qrs := runQuery(t, compiler, "data.main.check", input)
if len(qrs) != 0 {
t.Errorf("Expected 0 results, got %d", len(qrs))
}
})
}
func TestExternalSourceParametrizedDynamicKeyFromInput(t *testing.T) {
t.Parallel()
prefix := ast.MustParseRef("data.directory.user")
source := &paramExternalSource{refs: []ast.Ref{prefix}, arity: 1}
// The key itself comes from input and is ground at eval time.
staticModule := ast.MustParseModule(`package main
check if data.directory.user[input.account].id == input.account`)
compiler := setupCompiler(t, prefix, source, staticModule)
input := ast.MustParseTerm(`{"account": "555"}`)
qrs := runQuery(t, compiler, "data.main.check", input)
if len(qrs) != 1 {
t.Fatalf("Expected 1 result, got %d", len(qrs))
}
if len(source.keys) == 0 || source.keys[len(source.keys)-1] != "555" {
t.Errorf("Expected source to be queried with key 555, got keys=%v", source.keys)
}
}
// unevenExternalSource is a parametrized (prefix) external source whose arity
// varies with the reference tail, so a single registered prefix (data.reg)
// serves sub-references of different nesting depth: data.reg.user[<k>] nests one
// level while data.reg.pair[<a>][<b>] nests two. This is only expressible
// because ParamArity is consulted at eval time with the reference tail — a
// compile-time constant, uniform across the prefix subtree, could not describe
// an uneven-depth tree under one prefix.
type unevenExternalSource struct {
refs []ast.Ref
calls int32
}
func (s *unevenExternalSource) Refs() []ast.Ref { return s.refs }
func (s *unevenExternalSource) getCallCount() int { return int(atomic.LoadInt32(&s.calls)) }
func (s *unevenExternalSource) Init(_ context.Context, ref ast.Ref) (ast.ExternalRuleIndex, error) {
return &unevenExternalIndex{prefix: ref, src: s}, nil
}
type unevenExternalIndex struct {
prefix ast.Ref
src *unevenExternalSource
}
func (*unevenExternalIndex) Opts() *ast.ExternalSourceOptions { return &ast.ExternalSourceOptions{} }
// ParamArity keys off the leading tail segment to decide how many elements are
// consumed as parameters: "user" nests one level deep, "pair" nests two.
func (*unevenExternalIndex) ParamArity(tail ast.Ref) int {
if len(tail) == 0 {
return 0
}
switch tail[0].Value {
case ast.String("user"):
return 2 // "user", <k>
case ast.String("pair"):
return 3 // "pair", <a>, <b>
default:
return 0
}
}
func (idx *unevenExternalIndex) Lookup(_ context.Context, opts ...ast.LookupOption) ([]*ast.Rule, ast.ExternalRuleIndex, error) {
atomic.AddInt32(&idx.src.calls, 1)
o := ast.LookupOptions{}
for _, opt := range opts {
opt(&o)
}
params := o.Params()
// Derive a value from the consumed parameters, distinct per depth.
var value string
switch len(params) {
case 2: // data.reg.user[<k>] -> params are "user", <k>
value = string(params[1].(ast.String))
case 3: // data.reg.pair[<a>][<b>] -> params are "pair", <a>, <b>
value = fmt.Sprintf("%s-%s", string(params[1].(ast.String)), string(params[2].(ast.String)))
default:
return nil, nil, fmt.Errorf("unexpected param count %d", len(params))
}
// The synthesized module is rooted at the registered prefix (data.reg); the
// evaluator layers the consumed parameter levels back on top, so id resolves
// at data.reg.user[<k>].id or data.reg.pair[<a>][<b>].id respectively.
pkgPath := idx.prefix.String()[len("data."):]
mod := ast.MustParseModule(fmt.Sprintf("package %s\nid := %q", pkgPath, value))
return mod.Rules, nil, nil
}
var _ ast.ParametrizedExternalRuleIndex = (*unevenExternalIndex)(nil)
func TestExternalSourceParametrizedUnevenDepth(t *testing.T) {
t.Parallel()
prefix := ast.MustParseRef("data.reg")
for _, tc := range []struct {
note string
module string
calls int
}{
{
note: "one level deep (data.reg.user[k])",
module: `package main
check if data.reg.user["u1"].id == "u1"`,
calls: 1,
},
{
note: "two levels deep (data.reg.pair[a][b])",
module: `package main
check if data.reg.pair["a"]["b"].id == "a-b"`,
calls: 1,
},
{
note: "both depths under one prefix in a single evaluation",
module: `package main
check if {
data.reg.user["u1"].id == "u1"
data.reg.pair["a"]["b"].id == "a-b"
}`,
calls: 2,
},
} {
t.Run(tc.note, func(t *testing.T) {
source := &unevenExternalSource{refs: []ast.Ref{prefix}}
compiler := setupCompiler(t, prefix, source, ast.MustParseModule(tc.module))
qrs := runQuery(t, compiler, "data.main.check", nil)
if len(qrs) != 1 {
t.Fatalf("Expected 1 result, got %d", len(qrs))
}
if got := source.getCallCount(); got != tc.calls {
t.Errorf("Expected %d lookup(s), got %d", tc.calls, got)
}
})
}
}
func TestExternalSourceParametrizedUnevenDepthInsufficientDepthUndefined(t *testing.T) {
t.Parallel()
prefix := ast.MustParseRef("data.reg")
source := &unevenExternalSource{refs: []ast.Ref{prefix}}
// "pair" declares arity 3 (the prefix followed by two keys), but this
// reference supplies only one key. There aren't enough elements to
// parametrize the source, so the reference is undefined and the source is
// never consulted.
staticModule := ast.MustParseModule(`package main
check if data.reg.pair["a"]`)
compiler := setupCompiler(t, prefix, source, staticModule)
qrs := runQuery(t, compiler, "data.main.check", nil)
if len(qrs) != 0 {
t.Errorf("Expected 0 results for insufficient depth, got %d", len(qrs))
}
if got := source.getCallCount(); got != 0 {
t.Errorf("Expected source not to be consulted, got %d lookup(s)", got)
}
}
func TestExternalSourceE2EWithInputOverrideNilInput(t *testing.T) {
t.Parallel()
externalModule := ast.MustParseModule(`package authz
allowed if input.user == "alice"`)
packageRef := ast.MustParseRef("data.authz")
source := &countingExternalSource{refs: []ast.Ref{packageRef}, rules: externalModule.Rules}
staticModule := ast.MustParseModule(`package main
check if data.authz.allowed with input as {"user": "alice"}`)
compiler := setupCompiler(t, packageRef, source, staticModule)
// Query with nil input — the with clause provides it.
// This previously panicked because evalWithPop skipped PopFrame
// when oldInput was nil, leaking a frame on the externalTreeStack.
qrs := runQuery(t, compiler, "data.main.check", nil)
if len(qrs) != 1 {
t.Errorf("Expected 1 result, got %d", len(qrs))
}
}