mirror of
https://github.com/open-policy-agent/opa.git
synced 2026-08-12 19:32:48 -06:00
a7265038f2
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>
729 lines
21 KiB
Go
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 ¶mExternalIndex{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 := ¶mExternalSource{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 := ¶mExternalSource{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 := ¶mExternalSource{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 := ¶mExternalSource{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))
|
|
}
|
|
}
|