-
Notifications
You must be signed in to change notification settings - Fork 27
CRD-driven CEL admission rules with async validator #374
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
fa27767
feat(admission/cel): add AdmissionCelEvent adapter
slashben 3027d4a
feat(admission/cel): add AdmissionCEL engine with program caching
slashben d56a9b2
feat: add CelRuleCreator and CelRuleEvaluator for CEL-based admission…
slashben ff8d2e6
feat: add RulesWatcher for k8s-admission Rules CRDs
slashben 544912e
refactor: inject RuleCreator into RBCache, switch validator to alert-…
slashben 3f82b85
feat: wire CEL admission engine, delete legacy R2000/R2001 static rules
slashben 7c7771f
test: add end-to-end integration tests for CEL admission rules
slashben 1572cda
chore: upgrade armoapi-go to v0.0.714, use upstream EventTypeK8sAdmis…
slashben 39a508b
fix: filter non-RuleBinding events in RBCache handlers
slashben 12018c2
feat: short-circuit admission requests from the operator's own SA
slashben 7794af6
feat: skip admission CEL evaluation for kinds no rule could match
slashben 904379e
feat: async admission validator with bounded worker pool
slashben d83a972
fix(evaluator): skip Pod-specific K8s enrichment for non-Pod kinds
slashben ac1fa82
fix(cel): evict program cache entries for removed rules
slashben d119cd5
fix(validator): drain queued jobs on worker shutdown
slashben 4fd9434
fix(creator): avoid unbounded multiplication in slice capacity hint
slashben 4d2cce5
fix(cel): cache compile errors and re-surface them on every lookup
slashben 98b0398
feat(cel): expose per-binding parameters to CEL expressions
slashben File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,231 @@ | ||
| package cel | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "reflect" | ||
| "sync" | ||
|
|
||
| armotypes "github.com/armosec/armoapi-go/armotypes" | ||
| "github.com/google/cel-go/cel" | ||
| "github.com/google/cel-go/common/types/ref" | ||
| "github.com/google/cel-go/ext" | ||
| ) | ||
|
|
||
| // cachedProgram stores either a compiled CEL program or the compilation error | ||
| // that prevented it. The error is cached intentionally so repeated evaluation | ||
| // of a broken expression re-surfaces the failure rather than silently treating | ||
| // it as "no match" — a misconfigured rule must be loud, not invisible. | ||
| type cachedProgram struct { | ||
| prog cel.Program | ||
| err error | ||
| } | ||
|
|
||
| // AdmissionCEL owns a CEL environment configured for evaluating expressions | ||
| // against AdmissionCelEvent values. Compiled programs are cached so repeated | ||
| // evaluation of the same expression avoids re-compilation. | ||
| type AdmissionCEL struct { | ||
| env *cel.Env | ||
| programCache map[string]cachedProgram | ||
| cacheMu sync.RWMutex | ||
| } | ||
|
|
||
| // NewAdmissionCEL creates a CEL environment with the AdmissionCelEvent and | ||
| // AdmissionCelUserInfo types registered as native Go types. The resulting | ||
| // environment exposes two variables: | ||
| // | ||
| // event — cel.AdmissionCelEvent | ||
| // eventType — string | ||
| func NewAdmissionCEL() (*AdmissionCEL, error) { | ||
| env, err := cel.NewEnv( | ||
| ext.NativeTypes( | ||
| reflect.TypeOf(&AdmissionCelEvent{}), | ||
| reflect.TypeOf(&AdmissionCelUserInfo{}), | ||
| ), | ||
| cel.Variable("event", cel.ObjectType("cel.AdmissionCelEvent")), | ||
| cel.Variable("eventType", cel.StringType), | ||
| // Map fields are injected as top-level variables because cel-go's | ||
| // NativeTypes does not support map[string]interface{} struct fields. | ||
| cel.Variable("object", cel.MapType(cel.StringType, cel.DynType)), | ||
| cel.Variable("oldObject", cel.MapType(cel.StringType, cel.DynType)), | ||
| cel.Variable("options", cel.MapType(cel.StringType, cel.DynType)), | ||
| // Per-binding parameter overrides (RuntimeAlertRuleBindingRule.Parameters). | ||
| // Rules can reference these as params["key"]. Always present (empty map | ||
| // when no binding parameters apply) so expressions remain compilable. | ||
| cel.Variable("params", cel.MapType(cel.StringType, cel.DynType)), | ||
| ext.Strings(), | ||
| ) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("creating CEL environment: %w", err) | ||
| } | ||
| return &AdmissionCEL{ | ||
| env: env, | ||
| programCache: make(map[string]cachedProgram), | ||
| }, nil | ||
| } | ||
|
|
||
| // CreateEvalContext builds a map suitable for passing to program.Eval. | ||
| // The returned map is safe to reuse across sequential EvaluateRuleWithContext | ||
| // calls for the same event. The map fields (Object, OldObject, Options) are | ||
| // injected as separate top-level variables because cel-go's native type system | ||
| // does not support map[string]interface{} struct fields. | ||
| // | ||
| // The "params" key is initialized to an empty map. The evaluator overrides it | ||
| // with the active binding's parameters before evaluating each rule, so a | ||
| // single context can serve multiple rules with different parameter sets. | ||
| func (c *AdmissionCEL) CreateEvalContext(event *AdmissionCelEvent) map[string]any { | ||
| ctx := map[string]any{ | ||
| "event": event, | ||
| "eventType": string(armotypes.EventTypeK8sAdmission), | ||
| "params": map[string]any{}, | ||
| } | ||
| if event.Object != nil { | ||
| ctx["object"] = event.Object | ||
| } else { | ||
| ctx["object"] = map[string]any{} | ||
| } | ||
| if event.OldObject != nil { | ||
| ctx["oldObject"] = event.OldObject | ||
| } else { | ||
| ctx["oldObject"] = map[string]any{} | ||
| } | ||
| if event.Options != nil { | ||
| ctx["options"] = event.Options | ||
| } else { | ||
| ctx["options"] = map[string]any{} | ||
| } | ||
| return ctx | ||
| } | ||
|
|
||
| // EvaluateRuleWithContext evaluates all expressions whose EventType matches the | ||
| // provided eventType. Returns true only when every matching expression | ||
| // evaluates to true (AND semantics). If no expressions match the provided | ||
| // eventType, returns false — the rule has no opinion for this event type, so | ||
| // it should not fire. | ||
| func (c *AdmissionCEL) EvaluateRuleWithContext(evalContext map[string]any, eventType armotypes.EventType, expressions []armotypes.RuleExpression) (bool, error) { | ||
| matched := false | ||
| for _, expr := range expressions { | ||
| if expr.EventType != eventType { | ||
| continue | ||
| } | ||
| matched = true | ||
|
|
||
| out, err := c.evaluateProgram(expr.Expression, evalContext) | ||
| if err != nil { | ||
| return false, err | ||
| } | ||
|
|
||
| boolVal, ok := out.Value().(bool) | ||
| if !ok { | ||
| return false, fmt.Errorf("expression returned %T, expected bool", out.Value()) | ||
| } | ||
| if !boolVal { | ||
| return false, nil // short-circuit | ||
| } | ||
| } | ||
|
|
||
| if !matched { | ||
| return false, nil | ||
| } | ||
| return true, nil | ||
| } | ||
|
|
||
| // EvaluateStringExpression compiles and evaluates a CEL expression that is | ||
| // expected to return a string (e.g. a message template). | ||
| func (c *AdmissionCEL) EvaluateStringExpression(evalContext map[string]any, expression string) (string, error) { | ||
| out, err := c.evaluateProgram(expression, evalContext) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| strVal, ok := out.Value().(string) | ||
| if !ok { | ||
| return "", fmt.Errorf("expression returned %T, expected string", out.Value()) | ||
| } | ||
| return strVal, nil | ||
| } | ||
|
|
||
| // ProgramCacheSize returns the number of entries in the program cache | ||
| // (including nil entries for compile failures). | ||
| func (c *AdmissionCEL) ProgramCacheSize() int { | ||
| c.cacheMu.RLock() | ||
| defer c.cacheMu.RUnlock() | ||
| return len(c.programCache) | ||
| } | ||
|
|
||
| // RetainOnly drops every cached program whose expression is not in the | ||
| // provided active set. Call this from the rule sync path after replacing the | ||
| // rule list so programs compiled for now-removed rules are released — the | ||
| // cache grows monotonically otherwise. | ||
| // | ||
| // Passing nil or an empty slice clears the entire cache. | ||
| func (c *AdmissionCEL) RetainOnly(activeExpressions []string) { | ||
| active := make(map[string]struct{}, len(activeExpressions)) | ||
| for _, expr := range activeExpressions { | ||
| active[expr] = struct{}{} | ||
| } | ||
|
|
||
| c.cacheMu.Lock() | ||
| defer c.cacheMu.Unlock() | ||
| for expr := range c.programCache { | ||
| if _, keep := active[expr]; !keep { | ||
| delete(c.programCache, expr) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // evaluateProgram compiles (or retrieves from cache) and evaluates a CEL | ||
| // expression against the provided context. Cached compile failures are | ||
| // returned as errors on every call — never silently swallowed. | ||
| func (c *AdmissionCEL) evaluateProgram(expression string, evalContext map[string]any) (ref.Val, error) { | ||
| prog, err := c.getOrCreateProgram(expression) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| out, _, err := prog.Eval(evalContext) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("evaluating expression %q: %w", expression, err) | ||
| } | ||
| return out, nil | ||
| } | ||
|
|
||
| // getOrCreateProgram returns a cached program or compiles one. Compile errors | ||
| // are cached and re-returned on every subsequent call for the same expression: | ||
| // a broken rule should fail loudly on every event, not be silently ignored | ||
| // after the first attempt. | ||
| func (c *AdmissionCEL) getOrCreateProgram(expression string) (cel.Program, error) { | ||
| c.cacheMu.RLock() | ||
| if cp, exists := c.programCache[expression]; exists { | ||
| c.cacheMu.RUnlock() | ||
| return cp.prog, cp.err | ||
| } | ||
| c.cacheMu.RUnlock() | ||
|
|
||
| return c.compileAndCache(expression) | ||
| } | ||
|
|
||
| func (c *AdmissionCEL) compileAndCache(expression string) (cel.Program, error) { | ||
| c.cacheMu.Lock() | ||
| defer c.cacheMu.Unlock() | ||
|
|
||
| // Double-check after acquiring write lock. | ||
| if cp, exists := c.programCache[expression]; exists { | ||
| return cp.prog, cp.err | ||
| } | ||
|
|
||
| ast, issues := c.env.Compile(expression) | ||
| if issues != nil && issues.Err() != nil { | ||
| err := fmt.Errorf("compiling expression %q: %w", expression, issues.Err()) | ||
| c.programCache[expression] = cachedProgram{err: err} | ||
| return nil, err | ||
| } | ||
|
|
||
| prog, err := c.env.Program(ast, cel.EvalOptions(cel.OptOptimize)) | ||
| if err != nil { | ||
| wrapped := fmt.Errorf("creating program for %q: %w", expression, err) | ||
| c.programCache[expression] = cachedProgram{err: wrapped} | ||
| return nil, wrapped | ||
| } | ||
|
|
||
| c.programCache[expression] = cachedProgram{prog: prog} | ||
| return prog, nil | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.