Skip to content

Commit 313f81e

Browse files
authored
Merge pull request #11 from stacklok/cel
Add generic CEL expression engine package
2 parents 42eefc0 + 4984a40 commit 313f81e

5 files changed

Lines changed: 814 additions & 0 deletions

File tree

cel/engine.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package cel provides a generic CEL expression engine for evaluating
5+
// expressions against arbitrary data contexts.
6+
package cel
7+
8+
import (
9+
"fmt"
10+
"sync"
11+
12+
"github.com/google/cel-go/cel"
13+
)
14+
15+
const (
16+
// DefaultMaxExpressionLength is the maximum allowed length for a CEL expression.
17+
// This limit prevents DoS attacks via excessively long expressions.
18+
DefaultMaxExpressionLength = 10000
19+
20+
// DefaultCostLimit is the default runtime cost limit for CEL program evaluation.
21+
// This prevents DoS attacks via expensive operations in expressions.
22+
DefaultCostLimit = 1000000
23+
)
24+
25+
// Engine provides CEL expression compilation and evaluation capabilities.
26+
// It is safe for concurrent use from multiple goroutines.
27+
type Engine struct {
28+
envCache *envCache
29+
factory envFactory
30+
maxExpressionLength int
31+
costLimit uint64
32+
}
33+
34+
// envFactory is a function that creates a CEL environment.
35+
type envFactory func() (*cel.Env, error)
36+
37+
// envCache holds a lazily-initialized CEL environment.
38+
type envCache struct {
39+
once sync.Once
40+
env *cel.Env
41+
err error
42+
}
43+
44+
// CompiledExpression represents a pre-compiled CEL program ready for evaluation.
45+
type CompiledExpression struct {
46+
source string
47+
program cel.Program
48+
}
49+
50+
// Source returns the original expression source string.
51+
func (ce *CompiledExpression) Source() string {
52+
return ce.source
53+
}
54+
55+
// NewEngine creates a new CEL engine with the specified variable declarations.
56+
// The options are passed to cel.NewEnv to configure the CEL environment.
57+
//
58+
// The engine is created with default limits for expression length and evaluation cost
59+
// to prevent denial-of-service attacks. Use WithMaxExpressionLength and WithCostLimit
60+
// to customize these limits if needed.
61+
//
62+
// Example usage:
63+
//
64+
// engine := cel.NewEngine(
65+
// cel.Variable("claims", cel.MapType(cel.StringType, cel.DynType)),
66+
// )
67+
func NewEngine(options ...cel.EnvOption) *Engine {
68+
return &Engine{
69+
envCache: &envCache{},
70+
maxExpressionLength: DefaultMaxExpressionLength,
71+
costLimit: DefaultCostLimit,
72+
factory: func() (*cel.Env, error) {
73+
return cel.NewEnv(options...)
74+
},
75+
}
76+
}
77+
78+
// WithMaxExpressionLength sets the maximum allowed length for CEL expressions.
79+
// Expressions exceeding this length will be rejected during compilation.
80+
func (e *Engine) WithMaxExpressionLength(maxLen int) *Engine {
81+
e.maxExpressionLength = maxLen
82+
return e
83+
}
84+
85+
// WithCostLimit sets the runtime cost limit for CEL program evaluation.
86+
// Programs that exceed this cost during evaluation will return an error.
87+
func (e *Engine) WithCostLimit(limit uint64) *Engine {
88+
e.costLimit = limit
89+
return e
90+
}
91+
92+
// getEnv returns the CEL environment, creating it lazily on first access.
93+
func (e *Engine) getEnv() (*cel.Env, error) {
94+
e.envCache.once.Do(func() {
95+
e.envCache.env, e.envCache.err = e.factory()
96+
})
97+
return e.envCache.env, e.envCache.err
98+
}
99+
100+
// Compile parses and compiles a CEL expression, returning a CompiledExpression
101+
// that can be evaluated multiple times against different contexts.
102+
//
103+
// Returns an error if the expression exceeds the maximum length, a ParseError
104+
// if the expression has syntax errors, or a CheckError if the expression has
105+
// type checking errors.
106+
func (e *Engine) Compile(expr string) (*CompiledExpression, error) {
107+
// Check expression length to prevent DoS via excessively long expressions
108+
if len(expr) > e.maxExpressionLength {
109+
return nil, fmt.Errorf("%w: expression length %d exceeds maximum of %d",
110+
ErrExpressionCheck, len(expr), e.maxExpressionLength)
111+
}
112+
113+
env, err := e.getEnv()
114+
if err != nil {
115+
return nil, fmt.Errorf("failed to get CEL environment: %w", err)
116+
}
117+
118+
// Parse the expression
119+
parsedAst, issues := env.Parse(expr)
120+
if issues.Err() != nil {
121+
return nil, newParseError(expr, issues)
122+
}
123+
124+
// Type check the expression
125+
checkedAst, issues := env.Check(parsedAst)
126+
if issues.Err() != nil {
127+
return nil, newCheckError(expr, issues)
128+
}
129+
130+
// Compile to a program with cost limit to prevent DoS via expensive operations
131+
program, err := env.Program(checkedAst, cel.CostLimit(e.costLimit))
132+
if err != nil {
133+
return nil, fmt.Errorf("failed to create CEL program for %q: %w", expr, err)
134+
}
135+
136+
return &CompiledExpression{
137+
source: expr,
138+
program: program,
139+
}, nil
140+
}
141+
142+
// Check verifies that a CEL expression is syntactically and semantically valid
143+
// without creating a compiled program. This is useful for configuration validation.
144+
//
145+
// Returns an error if the expression exceeds the maximum length, a ParseError
146+
// if the expression has syntax errors, or a CheckError if the expression has
147+
// type checking errors.
148+
func (e *Engine) Check(expr string) error {
149+
// Check expression length to prevent DoS via excessively long expressions
150+
if len(expr) > e.maxExpressionLength {
151+
return fmt.Errorf("%w: expression length %d exceeds maximum of %d",
152+
ErrExpressionCheck, len(expr), e.maxExpressionLength)
153+
}
154+
155+
env, err := e.getEnv()
156+
if err != nil {
157+
return fmt.Errorf("failed to get CEL environment: %w", err)
158+
}
159+
160+
// Parse the expression
161+
parsedAst, issues := env.Parse(expr)
162+
if issues.Err() != nil {
163+
return newParseError(expr, issues)
164+
}
165+
166+
// Type check the expression
167+
_, issues = env.Check(parsedAst)
168+
if issues.Err() != nil {
169+
return newCheckError(expr, issues)
170+
}
171+
172+
return nil
173+
}
174+
175+
// Evaluate executes the compiled expression against the provided context
176+
// and returns the result. The context should contain values for all variables
177+
// declared when creating the Engine.
178+
//
179+
// Example:
180+
//
181+
// ctx := map[string]any{"myVar": someValue}
182+
func (ce *CompiledExpression) Evaluate(ctx map[string]any) (any, error) {
183+
out, _, err := ce.program.Eval(ctx)
184+
if err != nil {
185+
return nil, fmt.Errorf("%w: %s", ErrEvaluation, err)
186+
}
187+
return out.Value(), nil
188+
}
189+
190+
// EvaluateBool executes the compiled expression and returns the result as a bool.
191+
// Returns an error if the expression does not evaluate to a boolean.
192+
func (ce *CompiledExpression) EvaluateBool(ctx map[string]any) (bool, error) {
193+
result, err := ce.Evaluate(ctx)
194+
if err != nil {
195+
return false, err
196+
}
197+
198+
boolResult, ok := result.(bool)
199+
if !ok {
200+
return false, fmt.Errorf("%w: expected bool, got %T", ErrInvalidResult, result)
201+
}
202+
203+
return boolResult, nil
204+
}

0 commit comments

Comments
 (0)