Skip to main content

kube_cel/validation/
vap.rs

1//! Client-side evaluation of Kubernetes ValidatingAdmissionPolicy CEL expressions.
2//!
3//! Supports all VAP variables except `authorizer` (requires API server).
4//!
5//! # Example
6//!
7//! ```rust
8//! use kube_cel::{AdmissionRequest, VapEvaluator, VapExpression};
9//! use serde_json::json;
10//!
11//! let evaluator = VapEvaluator::builder()
12//!     .object(json!({"spec": {"replicas": 3}}))
13//!     .request(AdmissionRequest {
14//!         operation: "CREATE".into(),
15//!         username: "admin".into(),
16//!         ..Default::default()
17//!     })
18//!     .build();
19//!
20//! let results = evaluator.evaluate(&[VapExpression {
21//!     expression: "object.spec.replicas >= 0".into(),
22//!     message: Some("replicas must be non-negative".into()),
23//!     message_expression: None,
24//! }]);
25//!
26//! assert!(results[0].passed);
27//! ```
28
29use std::{collections::HashMap, sync::Arc};
30
31use cel::{
32    Context, Program, Value,
33    objects::{Key, Map},
34};
35
36use crate::validation::values::json_to_cel;
37
38/// Group/Version/Kind identifier.
39#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
40pub struct GroupVersionKind {
41    /// API group (e.g., `"apps"`). Empty string for the core group.
42    pub group: String,
43    /// API version (e.g., `"v1"`).
44    pub version: String,
45    /// Resource kind (e.g., `"Deployment"`).
46    pub kind: String,
47}
48
49/// Group/Version/Resource identifier.
50#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
51pub struct GroupVersionResource {
52    /// API group (e.g., `"apps"`). Empty string for the core group.
53    pub group: String,
54    /// API version (e.g., `"v1"`).
55    pub version: String,
56    /// Resource name (e.g., `"deployments"`).
57    pub resource: String,
58}
59
60/// A request context for VAP evaluation.
61///
62/// Mirrors the `request` variable available in Kubernetes ValidatingAdmissionPolicy
63/// CEL expressions. This is a flat projection for CEL binding, **not** the admission
64/// webhook wire type; if you have a `kube_core::admission::AdmissionRequest<T>`,
65/// convert it via `to_cel_request()`.
66#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct AdmissionRequest {
69    /// The admission operation: `"CREATE"`, `"UPDATE"`, `"DELETE"`, or `"CONNECT"`.
70    pub operation: String,
71    /// The authenticated username of the requesting user.
72    pub username: String,
73    /// The UID of the requesting user.
74    pub uid: String,
75    /// The group memberships of the requesting user.
76    pub groups: Vec<String>,
77    /// The name of the resource being admitted.
78    pub name: String,
79    /// The namespace of the resource being admitted.
80    pub namespace: String,
81    /// Whether the request is a dry-run.
82    pub dry_run: bool,
83    /// The kind of the object being admitted.
84    pub kind: GroupVersionKind,
85    /// The resource being admitted.
86    pub resource: GroupVersionResource,
87}
88
89/// A single CEL validation expression from a ValidatingAdmissionPolicy.
90#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct VapExpression {
93    /// The CEL expression to evaluate. Must evaluate to a boolean.
94    pub expression: String,
95    /// Static error message returned when the expression evaluates to `false`.
96    pub message: Option<String>,
97    /// CEL expression evaluated to produce the error message.
98    /// Takes precedence over `message` when evaluation succeeds.
99    pub message_expression: Option<String>,
100}
101
102/// The result of evaluating a single [`VapExpression`].
103///
104/// `#[non_exhaustive]`: an output type the crate constructs; new fields may be
105/// added without a breaking change.
106#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
107#[non_exhaustive]
108pub struct VapResult {
109    /// The original CEL expression.
110    pub expression: String,
111    /// Whether the expression evaluated to `true` (admission allowed).
112    pub passed: bool,
113    /// Error message when `passed` is `false`. `None` if the expression passed.
114    pub message: Option<String>,
115}
116
117/// A pre-compiled VAP expression for repeated evaluation.
118///
119/// Created by [`VapEvaluator::compile_expressions`]. Since [`cel::Program`] is
120/// `!Clone`, wrap in [`Arc`] for shared ownership if needed.
121pub struct CompiledVapExpression {
122    program: Program,
123    expression: String,
124    message: Option<String>,
125    message_program: Option<Program>,
126}
127
128impl std::fmt::Debug for CompiledVapExpression {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("CompiledVapExpression")
131            .field("expression", &self.expression)
132            .field("message", &self.message)
133            .field("has_message_program", &self.message_program.is_some())
134            .finish_non_exhaustive()
135    }
136}
137
138/// An error produced when a VAP expression fails to compile.
139///
140/// Returned in the `Err` arm of [`VapEvaluator::compile_expressions`]. The
141/// underlying `cel` parse error is available via [`std::error::Error::source`];
142/// the concrete `cel` type is kept out of the public surface (held behind a
143/// boxed `dyn Error`) so it is not frozen into the API.
144///
145/// `#[non_exhaustive]`: VAP compilation has a single failure mode today; new
146/// fields may be added without a breaking change.
147#[derive(Debug)]
148#[non_exhaustive]
149pub struct VapError {
150    /// The VAP expression that failed to compile.
151    pub expression: String,
152    source: Box<dyn std::error::Error + Send + Sync>,
153}
154
155impl std::fmt::Display for VapError {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        write!(
158            f,
159            "failed to compile VAP expression \"{}\": {}",
160            self.expression, self.source
161        )
162    }
163}
164
165impl std::error::Error for VapError {
166    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
167        Some(&*self.source)
168    }
169}
170
171/// Client-side evaluator for Kubernetes ValidatingAdmissionPolicy CEL expressions.
172///
173/// Binds `object`, `oldObject`, `request`, and optionally `params` and
174/// `namespaceObject` into a CEL context, then evaluates one or more
175/// [`VapExpression`]s.
176///
177/// Construct via [`VapEvaluator::builder()`].
178#[derive(Debug)]
179pub struct VapEvaluator {
180    object: serde_json::Value,
181    old_object: Option<serde_json::Value>,
182    request: AdmissionRequest,
183    params: Option<serde_json::Value>,
184    namespace_object: Option<serde_json::Value>,
185}
186
187/// Builder for [`VapEvaluator`].
188#[derive(Debug, Default)]
189pub struct VapEvaluatorBuilder {
190    object: Option<serde_json::Value>,
191    old_object: Option<serde_json::Value>,
192    request: AdmissionRequest,
193    params: Option<serde_json::Value>,
194    namespace_object: Option<serde_json::Value>,
195}
196
197impl VapEvaluatorBuilder {
198    /// Set the object being admitted (`object` variable).
199    pub fn object(mut self, obj: serde_json::Value) -> Self {
200        self.object = Some(obj);
201        self
202    }
203
204    /// Set the previous version of the object (`oldObject` variable).
205    /// If not set, `oldObject` will be `null` (typical for CREATE operations).
206    pub fn old_object(mut self, obj: serde_json::Value) -> Self {
207        self.old_object = Some(obj);
208        self
209    }
210
211    /// Set the admission request context (`request` variable).
212    pub fn request(mut self, req: AdmissionRequest) -> Self {
213        self.request = req;
214        self
215    }
216
217    /// Set the policy parameters (`params` variable).
218    /// Only bound when provided.
219    pub fn params(mut self, p: serde_json::Value) -> Self {
220        self.params = Some(p);
221        self
222    }
223
224    /// Set the namespace object (`namespaceObject` variable).
225    /// Only bound when provided.
226    pub fn namespace_object(mut self, ns: serde_json::Value) -> Self {
227        self.namespace_object = Some(ns);
228        self
229    }
230
231    /// Consume the builder and produce a [`VapEvaluator`].
232    pub fn build(self) -> VapEvaluator {
233        VapEvaluator {
234            object: self.object.unwrap_or(serde_json::Value::Null),
235            old_object: self.old_object,
236            request: self.request,
237            params: self.params,
238            namespace_object: self.namespace_object,
239        }
240    }
241}
242
243impl VapEvaluator {
244    /// Create a new [`VapEvaluatorBuilder`].
245    pub fn builder() -> VapEvaluatorBuilder {
246        VapEvaluatorBuilder::default()
247    }
248
249    /// Build a CEL [`Context`] with all VAP variables bound.
250    fn build_context(&self) -> Context<'static> {
251        let mut ctx = Context::default();
252        crate::register_all(&mut ctx);
253
254        // Bind `object`
255        let _ = ctx.add_variable("object", json_to_cel(&self.object));
256
257        // Bind `oldObject` — null when not provided (e.g. CREATE)
258        let old_object_val = match &self.old_object {
259            Some(v) => json_to_cel(v),
260            None => Value::Null,
261        };
262        let _ = ctx.add_variable("oldObject", old_object_val);
263
264        // Bind `request`
265        let _ = ctx.add_variable("request", request_to_cel(&self.request));
266
267        // Bind `params` only when provided
268        if let Some(params) = &self.params {
269            let _ = ctx.add_variable("params", json_to_cel(params));
270        }
271
272        // Bind `namespaceObject` only when provided
273        if let Some(ns) = &self.namespace_object {
274            let _ = ctx.add_variable("namespaceObject", json_to_cel(ns));
275        }
276
277        ctx
278    }
279
280    /// Pre-compile validation expressions for repeated evaluation.
281    ///
282    /// Returns one entry per input expression. Failed compilations are
283    /// represented as `Err(`[`VapError`]`)`, which carries the offending
284    /// expression and the underlying `cel` parse error (via
285    /// [`source`](std::error::Error::source)).
286    #[must_use]
287    pub fn compile_expressions(
288        &self,
289        expressions: &[VapExpression],
290    ) -> Vec<Result<CompiledVapExpression, VapError>> {
291        expressions
292            .iter()
293            .map(|expr| {
294                let program = Program::compile(&expr.expression).map_err(|e| VapError {
295                    expression: expr.expression.clone(),
296                    source: Box::new(e),
297                })?;
298                // A messageExpression that fails to compile fails closed (the
299                // apiserver rejects such a policy at registration), mirroring the
300                // expression path above rather than silently dropping it.
301                let message_program = match expr.message_expression.as_deref() {
302                    Some(me) => Some(Program::compile(me).map_err(|e| VapError {
303                        expression: me.to_string(),
304                        source: Box::new(e),
305                    })?),
306                    None => None,
307                };
308                Ok(CompiledVapExpression {
309                    program,
310                    expression: expr.expression.clone(),
311                    message: expr.message.clone(),
312                    message_program,
313                })
314            })
315            .collect()
316    }
317
318    /// Evaluate pre-compiled expressions against the bound context.
319    ///
320    /// The context is built once and all compiled expressions are executed
321    /// against it. Expressions that failed compilation (represented as
322    /// `Err`) are returned as failed [`VapResult`]s.
323    #[must_use]
324    pub fn evaluate_compiled(&self, compiled: &[Result<CompiledVapExpression, VapError>]) -> Vec<VapResult> {
325        let ctx = self.build_context();
326
327        compiled
328            .iter()
329            .map(|c| match c {
330                Ok(ce) => match ce.program.execute(&ctx) {
331                    Ok(Value::Bool(true)) => VapResult {
332                        expression: ce.expression.clone(),
333                        passed: true,
334                        message: None,
335                    },
336                    Ok(Value::Bool(false)) => {
337                        let msg = ce
338                            .message_program
339                            .as_ref()
340                            .and_then(|prog| match prog.execute(&ctx) {
341                                Ok(Value::String(s)) => Some((*s).clone()),
342                                _ => None,
343                            })
344                            .or_else(|| ce.message.clone())
345                            .unwrap_or_else(|| {
346                                format!("validation expression '{}' evaluated to false", ce.expression)
347                            });
348                        VapResult {
349                            expression: ce.expression.clone(),
350                            passed: false,
351                            message: Some(msg),
352                        }
353                    }
354                    Ok(other) => VapResult {
355                        expression: ce.expression.clone(),
356                        passed: false,
357                        message: Some(format!("expression returned non-boolean: {other:?}")),
358                    },
359                    Err(e) => VapResult {
360                        expression: ce.expression.clone(),
361                        passed: false,
362                        message: Some(format!("evaluation error: {e}")),
363                    },
364                },
365                Err(e) => VapResult {
366                    expression: e.expression.clone(),
367                    passed: false,
368                    message: Some(e.to_string()),
369                },
370            })
371            .collect()
372    }
373
374    /// Evaluate a slice of [`VapExpression`]s against the bound context.
375    ///
376    /// Returns one [`VapResult`] per expression in the same order.
377    /// Expressions that fail to compile or execute are treated as failures
378    /// with a descriptive error message.
379    #[must_use]
380    pub fn evaluate(&self, expressions: &[VapExpression]) -> Vec<VapResult> {
381        let compiled = self.compile_expressions(expressions);
382        self.evaluate_compiled(&compiled)
383    }
384}
385
386/// Convert an [`AdmissionRequest`] to a CEL [`Value::Map`].
387///
388/// Produces a map with the following shape (mirroring the K8s admission `request` variable):
389/// ```text
390/// {
391///   "operation": string,
392///   "name":      string,
393///   "namespace": string,
394///   "dryRun":    bool,
395///   "kind":     { "group": string, "version": string, "kind": string },
396///   "resource": { "group": string, "version": string, "resource": string },
397///   "userInfo": { "username": string, "uid": string, "groups": list<string> },
398/// }
399/// ```
400fn request_to_cel(req: &AdmissionRequest) -> Value {
401    let mut map: HashMap<Key, Value> = HashMap::new();
402
403    map.insert(
404        Key::String(Arc::new("operation".into())),
405        Value::String(Arc::new(req.operation.clone())),
406    );
407    map.insert(
408        Key::String(Arc::new("name".into())),
409        Value::String(Arc::new(req.name.clone())),
410    );
411    map.insert(
412        Key::String(Arc::new("namespace".into())),
413        Value::String(Arc::new(req.namespace.clone())),
414    );
415    map.insert(Key::String(Arc::new("dryRun".into())), Value::Bool(req.dry_run));
416
417    // kind: { group, version, kind }
418    let mut kind_map: HashMap<Key, Value> = HashMap::new();
419    kind_map.insert(
420        Key::String(Arc::new("group".into())),
421        Value::String(Arc::new(req.kind.group.clone())),
422    );
423    kind_map.insert(
424        Key::String(Arc::new("version".into())),
425        Value::String(Arc::new(req.kind.version.clone())),
426    );
427    kind_map.insert(
428        Key::String(Arc::new("kind".into())),
429        Value::String(Arc::new(req.kind.kind.clone())),
430    );
431    map.insert(
432        Key::String(Arc::new("kind".into())),
433        Value::Map(Map {
434            map: Arc::new(kind_map),
435        }),
436    );
437
438    // resource: { group, version, resource }
439    let mut resource_map: HashMap<Key, Value> = HashMap::new();
440    resource_map.insert(
441        Key::String(Arc::new("group".into())),
442        Value::String(Arc::new(req.resource.group.clone())),
443    );
444    resource_map.insert(
445        Key::String(Arc::new("version".into())),
446        Value::String(Arc::new(req.resource.version.clone())),
447    );
448    resource_map.insert(
449        Key::String(Arc::new("resource".into())),
450        Value::String(Arc::new(req.resource.resource.clone())),
451    );
452    map.insert(
453        Key::String(Arc::new("resource".into())),
454        Value::Map(Map {
455            map: Arc::new(resource_map),
456        }),
457    );
458
459    // userInfo: { username, uid, groups }
460    let groups_list: Vec<Value> = req
461        .groups
462        .iter()
463        .map(|g| Value::String(Arc::new(g.clone())))
464        .collect();
465    let mut user_info_map: HashMap<Key, Value> = HashMap::new();
466    user_info_map.insert(
467        Key::String(Arc::new("username".into())),
468        Value::String(Arc::new(req.username.clone())),
469    );
470    user_info_map.insert(
471        Key::String(Arc::new("uid".into())),
472        Value::String(Arc::new(req.uid.clone())),
473    );
474    user_info_map.insert(
475        Key::String(Arc::new("groups".into())),
476        Value::List(Arc::new(groups_list)),
477    );
478    map.insert(
479        Key::String(Arc::new("userInfo".into())),
480        Value::Map(Map {
481            map: Arc::new(user_info_map),
482        }),
483    );
484
485    Value::Map(Map { map: Arc::new(map) })
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use serde_json::json;
492
493    #[test]
494    fn vap_basic_validation_passes() {
495        let evaluator = VapEvaluator::builder()
496            .object(json!({"metadata": {"name": "test"}, "spec": {"replicas": 3}}))
497            .request(AdmissionRequest {
498                operation: "CREATE".into(),
499                ..Default::default()
500            })
501            .build();
502        let results = evaluator.evaluate(&[VapExpression {
503            expression: "object.spec.replicas >= 0".into(),
504            message: Some("replicas must be non-negative".into()),
505            message_expression: None,
506        }]);
507        assert_eq!(results.len(), 1);
508        assert!(results[0].passed);
509    }
510
511    #[test]
512    fn compile_failure_yields_vap_error_with_cause() {
513        use std::error::Error;
514        let evaluator = VapEvaluator::builder().build();
515        let compiled = evaluator.compile_expressions(&[VapExpression {
516            expression: "this is ((( not valid".into(),
517            message: None,
518            message_expression: None,
519        }]);
520        let err = compiled[0].as_ref().unwrap_err();
521        assert_eq!(err.expression, "this is ((( not valid");
522        assert!(
523            err.source().is_some(),
524            "VapError should chain the cel parse error"
525        );
526
527        // And evaluate_compiled surfaces the failure with the real expression.
528        let results = evaluator.evaluate_compiled(&compiled);
529        assert!(!results[0].passed);
530        assert_eq!(results[0].expression, "this is ((( not valid");
531    }
532
533    #[test]
534    fn invalid_message_expression_fails_closed() {
535        use std::error::Error;
536        // The expression itself is valid; only its messageExpression is broken.
537        // The apiserver rejects such a policy at registration, so a broken
538        // messageExpression must fail closed here too, not be silently dropped.
539        let evaluator = VapEvaluator::builder().object(json!({})).build();
540        let compiled = evaluator.compile_expressions(&[VapExpression {
541            expression: "true".into(),
542            message: Some("fallback".into()),
543            message_expression: Some("invalid >=".into()),
544        }]);
545        let err = compiled[0].as_ref().unwrap_err();
546        // The error points at the broken messageExpression and chains its cause.
547        assert_eq!(err.expression, "invalid >=");
548        assert!(err.source().is_some(), "VapError should chain the cel cause");
549
550        // And evaluate_compiled surfaces it as a failed result (fail closed).
551        let results = evaluator.evaluate_compiled(&compiled);
552        assert!(!results[0].passed);
553    }
554
555    #[test]
556    fn vap_validation_fails_with_message() {
557        let evaluator = VapEvaluator::builder()
558            .object(json!({"spec": {"replicas": -1}}))
559            .request(AdmissionRequest {
560                operation: "CREATE".into(),
561                ..Default::default()
562            })
563            .build();
564        let results = evaluator.evaluate(&[VapExpression {
565            expression: "object.spec.replicas >= 0".into(),
566            message: Some("replicas must be non-negative".into()),
567            message_expression: None,
568        }]);
569        assert!(!results[0].passed);
570        assert_eq!(
571            results[0].message.as_deref(),
572            Some("replicas must be non-negative")
573        );
574    }
575
576    #[test]
577    fn vap_request_variables_accessible() {
578        let evaluator = VapEvaluator::builder()
579            .object(json!({"spec": {}}))
580            .request(AdmissionRequest {
581                operation: "CREATE".into(),
582                username: "admin".into(),
583                namespace: "default".into(),
584                ..Default::default()
585            })
586            .build();
587        let results = evaluator.evaluate(&[VapExpression {
588            expression: "request.operation == 'CREATE' && request.userInfo.username == 'admin'".into(),
589            message: None,
590            message_expression: None,
591        }]);
592        assert!(results[0].passed);
593    }
594
595    #[test]
596    fn vap_old_object_null_on_create() {
597        let evaluator = VapEvaluator::builder()
598            .object(json!({"spec": {}}))
599            .request(AdmissionRequest {
600                operation: "CREATE".into(),
601                ..Default::default()
602            })
603            .build();
604        let results = evaluator.evaluate(&[VapExpression {
605            expression: "oldObject == null".into(),
606            message: None,
607            message_expression: None,
608        }]);
609        assert!(results[0].passed);
610    }
611
612    #[test]
613    fn vap_params_accessible() {
614        let evaluator = VapEvaluator::builder()
615            .object(json!({"spec": {"replicas": 5}}))
616            .params(json!({"maxReplicas": 10}))
617            .request(AdmissionRequest::default())
618            .build();
619        let results = evaluator.evaluate(&[VapExpression {
620            expression: "object.spec.replicas <= params.maxReplicas".into(),
621            message: None,
622            message_expression: None,
623        }]);
624        assert!(results[0].passed);
625    }
626
627    #[test]
628    fn vap_message_expression() {
629        let evaluator = VapEvaluator::builder()
630            .object(json!({"spec": {"replicas": -1}}))
631            .request(AdmissionRequest::default())
632            .build();
633        let results = evaluator.evaluate(&[VapExpression {
634            expression: "object.spec.replicas >= 0".into(),
635            message: Some("static fallback".into()),
636            message_expression: Some("'replicas is ' + string(object.spec.replicas)".into()),
637        }]);
638        assert!(!results[0].passed);
639        assert_eq!(results[0].message.as_deref(), Some("replicas is -1"));
640    }
641
642    #[test]
643    fn vap_compiled_expressions_reusable() {
644        let evaluator = VapEvaluator::builder()
645            .object(json!({"spec": {"replicas": 3}}))
646            .request(AdmissionRequest {
647                operation: "CREATE".into(),
648                ..Default::default()
649            })
650            .build();
651
652        let expressions = vec![VapExpression {
653            expression: "object.spec.replicas >= 0".into(),
654            message: Some("bad".into()),
655            message_expression: None,
656        }];
657
658        let compiled = evaluator.compile_expressions(&expressions);
659        assert!(compiled[0].is_ok());
660
661        let r1 = evaluator.evaluate_compiled(&compiled);
662        let r2 = evaluator.evaluate_compiled(&compiled);
663        assert!(r1[0].passed);
664        assert!(r2[0].passed);
665    }
666
667    #[test]
668    fn vap_compiled_error_preserved() {
669        let evaluator = VapEvaluator::builder()
670            .object(json!({}))
671            .request(AdmissionRequest::default())
672            .build();
673
674        let expressions = vec![VapExpression {
675            expression: "invalid >=".into(),
676            message: None,
677            message_expression: None,
678        }];
679
680        let compiled = evaluator.compile_expressions(&expressions);
681        assert!(compiled[0].is_err());
682
683        let results = evaluator.evaluate_compiled(&compiled);
684        assert!(!results[0].passed);
685        assert!(
686            results[0]
687                .message
688                .as_ref()
689                .unwrap()
690                .contains("failed to compile VAP expression")
691        );
692    }
693
694    #[test]
695    fn vap_multiple_expressions() {
696        let evaluator = VapEvaluator::builder()
697            .object(json!({"spec": {"replicas": -1, "name": ""}}))
698            .request(AdmissionRequest::default())
699            .build();
700        let results = evaluator.evaluate(&[
701            VapExpression {
702                expression: "object.spec.replicas >= 0".into(),
703                message: Some("bad replicas".into()),
704                message_expression: None,
705            },
706            VapExpression {
707                expression: "object.spec.name.size() > 0".into(),
708                message: Some("name required".into()),
709                message_expression: None,
710            },
711        ]);
712        assert_eq!(results.len(), 2);
713        assert!(!results[0].passed);
714        assert!(!results[1].passed);
715    }
716}