kube_core/admission.rs
1//! Contains types for implementing admission controllers.
2//!
3//! For more information on admission controllers, see:
4//! <https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/>
5//! <https://kubernetes.io/blog/2019/03/21/a-guide-to-kubernetes-admission-controllers/>
6//! <https://github.com/kubernetes/api/blob/master/admission/v1/types.go>
7
8use crate::{
9 Status,
10 dynamic::DynamicObject,
11 gvk::{GroupVersionKind, GroupVersionResource},
12 metadata::TypeMeta,
13 resource::Resource,
14};
15
16use std::collections::HashMap;
17
18use k8s_openapi::{api::authentication::v1::UserInfo, apimachinery::pkg::runtime::RawExtension};
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22#[derive(Debug, Error)]
23#[error("failed to serialize patch: {0}")]
24/// Failed to serialize patch.
25pub struct SerializePatchError(#[source] serde_json::Error);
26
27#[derive(Debug, Error)]
28#[error("failed to convert AdmissionReview into AdmissionRequest")]
29/// Failed to convert `AdmissionReview` into `AdmissionRequest`.
30pub struct ConvertAdmissionReviewError;
31
32/// The `kind` field in [`TypeMeta`].
33pub const META_KIND: &str = "AdmissionReview";
34/// The `api_version` field in [`TypeMeta`] on the v1 version.
35pub const META_API_VERSION_V1: &str = "admission.k8s.io/v1";
36
37/// The top level struct used for Serializing and Deserializing AdmissionReview
38/// requests and responses.
39///
40/// This is both the input type received by admission controllers, and the
41/// output type admission controllers should return.
42///
43/// An admission controller should start by inspecting the [`AdmissionRequest`].
44#[derive(Serialize, Deserialize, Clone, Debug)]
45#[serde(rename_all = "camelCase")]
46pub struct AdmissionReview<T: Resource> {
47 /// Contains the API version and type of the request.
48 #[serde(flatten)]
49 pub types: TypeMeta,
50 /// Describes the attributes for the admission request.
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub request: Option<AdmissionRequest<T>>,
53 /// Describes the attributes for the admission response.
54 #[serde(skip_serializing_if = "Option::is_none")]
55 #[serde(default)]
56 pub response: Option<AdmissionResponse>,
57}
58
59impl<T: Resource> TryInto<AdmissionRequest<T>> for AdmissionReview<T> {
60 type Error = ConvertAdmissionReviewError;
61
62 fn try_into(self) -> Result<AdmissionRequest<T>, Self::Error> {
63 match self.request {
64 Some(mut req) => {
65 req.types = self.types;
66 Ok(req)
67 }
68 None => Err(ConvertAdmissionReviewError),
69 }
70 }
71}
72
73/// An incoming [`AdmissionReview`] request.
74///
75/// In an admission controller scenario, this is extracted from an [`AdmissionReview`] via [`TryInto`]
76///
77/// ```no_run
78/// use kube::core::{admission::{AdmissionRequest, AdmissionReview}, DynamicObject};
79///
80/// // The incoming AdmissionReview received by the controller.
81/// let body: AdmissionReview<DynamicObject> = todo!();
82/// let req: AdmissionRequest<_> = body.try_into().unwrap();
83/// ```
84///
85/// Based on the contents of the request, an admission controller should construct an
86/// [`AdmissionResponse`] using:
87///
88/// - [`AdmissionResponse::deny`] for illegal/rejected requests
89/// - [`AdmissionResponse::invalid`] for malformed requests
90/// - [`AdmissionResponse::from`] for the happy path
91///
92/// then wrap the chosen response in an [`AdmissionReview`] via [`AdmissionResponse::into_review`].
93#[derive(Serialize, Deserialize, Clone, Debug)]
94#[serde(rename_all = "camelCase")]
95pub struct AdmissionRequest<T: Resource> {
96 /// Copied from the containing [`AdmissionReview`] and used to specify a
97 /// response type and version when constructing an [`AdmissionResponse`].
98 #[serde(skip)]
99 pub types: TypeMeta,
100 /// An identifier for the individual request/response. It allows us to
101 /// distinguish instances of requests which are otherwise identical (parallel
102 /// requests, requests when earlier requests did not modify, etc). The UID is
103 /// meant to track the round trip (request/response) between the KAS and the
104 /// webhook, not the user request. It is suitable for correlating log entries
105 /// between the webhook and apiserver, for either auditing or debugging.
106 pub uid: String,
107 /// The fully-qualified type of object being submitted (for example, v1.Pod
108 /// or autoscaling.v1.Scale).
109 pub kind: GroupVersionKind,
110 /// The fully-qualified resource being requested (for example, v1.pods).
111 pub resource: GroupVersionResource,
112 /// The subresource being requested, if any (for example, "status" or
113 /// "scale").
114 #[serde(default)]
115 pub sub_resource: Option<String>,
116 /// The fully-qualified type of the original API request (for example, v1.Pod
117 /// or autoscaling.v1.Scale). If this is specified and differs from the value
118 /// in "kind", an equivalent match and conversion was performed.
119 ///
120 /// For example, if deployments can be modified via apps/v1 and apps/v1beta1,
121 /// and a webhook registered a rule of `apiGroups:["apps"],
122 /// apiVersions:["v1"], resources:["deployments"]` and
123 /// `matchPolicy:Equivalent`, an API request to apps/v1beta1 deployments
124 /// would be converted and sent to the webhook with `kind: {group:"apps",
125 /// version:"v1", kind:"Deployment"}` (matching the rule the webhook
126 /// registered for), and `requestKind: {group:"apps", version:"v1beta1",
127 /// kind:"Deployment"}` (indicating the kind of the original API request).
128 /// See documentation for the "matchPolicy" field in the webhook
129 /// configuration type for more details.
130 #[serde(default)]
131 pub request_kind: Option<GroupVersionKind>,
132 /// The fully-qualified resource of the original API request (for example,
133 /// v1.pods). If this is specified and differs from the value in "resource",
134 /// an equivalent match and conversion was performed.
135 ///
136 /// For example, if deployments can be modified via apps/v1 and apps/v1beta1,
137 /// and a webhook registered a rule of `apiGroups:["apps"],
138 /// apiVersions:["v1"], resources: ["deployments"]` and `matchPolicy:
139 /// Equivalent`, an API request to apps/v1beta1 deployments would be
140 /// converted and sent to the webhook with `resource: {group:"apps",
141 /// version:"v1", resource:"deployments"}` (matching the resource the webhook
142 /// registered for), and `requestResource: {group:"apps", version:"v1beta1",
143 /// resource:"deployments"}` (indicating the resource of the original API
144 /// request).
145 ///
146 /// See documentation for the "matchPolicy" field in the webhook
147 /// configuration type.
148 #[serde(default)]
149 pub request_resource: Option<GroupVersionResource>,
150 /// The name of the subresource of the original API request, if any (for
151 /// example, "status" or "scale"). If this is specified and differs from the
152 /// value in "subResource", an equivalent match and conversion was performed.
153 /// See documentation for the "matchPolicy" field in the webhook
154 /// configuration type.
155 #[serde(default)]
156 pub request_sub_resource: Option<String>,
157 /// The name of the object as presented in the request. On a CREATE
158 /// operation, the client may omit name and rely on the server to generate
159 /// the name. If that is the case, this field will contain an empty string.
160 #[serde(default)]
161 pub name: String,
162 /// The namespace associated with the request (if any).
163 #[serde(default)]
164 pub namespace: Option<String>,
165 /// The operation being performed. This may be different than the operation
166 /// requested. e.g. a patch can result in either a CREATE or UPDATE
167 /// Operation.
168 pub operation: Operation,
169 /// Information about the requesting user.
170 pub user_info: UserInfo,
171 /// The object from the incoming request. It's `None` for [`DELETE`](Operation::Delete) operations.
172 pub object: Option<T>,
173 /// The existing object. Only populated for DELETE and UPDATE requests.
174 pub old_object: Option<T>,
175 /// Specifies that modifications will definitely not be persisted for this
176 /// request.
177 #[serde(default)]
178 pub dry_run: bool,
179 /// The operation option structure of the operation being performed. e.g.
180 /// `meta.k8s.io/v1.DeleteOptions` or `meta.k8s.io/v1.CreateOptions`. This
181 /// may be different than the options the caller provided. e.g. for a patch
182 /// request the performed [`Operation`] might be a [`CREATE`](Operation::Create), in
183 /// which case the Options will a `meta.k8s.io/v1.CreateOptions` even though
184 /// the caller provided `meta.k8s.io/v1.PatchOptions`.
185 #[serde(default)]
186 pub options: Option<RawExtension>,
187}
188
189/// The operation specified in an [`AdmissionRequest`].
190#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
191#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
192pub enum Operation {
193 /// An operation that creates a resource.
194 Create,
195 /// An operation that updates a resource.
196 Update,
197 /// An operation that deletes a resource.
198 Delete,
199 /// An operation that connects to a resource.
200 Connect,
201}
202
203#[cfg(feature = "cel")]
204#[cfg_attr(docsrs, doc(cfg(feature = "cel")))]
205impl<T: Resource> AdmissionRequest<T> {
206 /// Project this request into the [`kube_cel::AdmissionRequest`] used to
207 /// bind the `request` variable for ValidatingAdmissionPolicy CEL evaluation.
208 ///
209 /// This is a lossy view: only the fields exposed to VAP's `request` variable
210 /// are carried over (`operation`, `name`, `namespace`, `dryRun`, `kind`,
211 /// `resource`, and `userInfo`'s `username`/`uid`/`groups`). Webhook-only fields
212 /// such as `object`, `oldObject`, `requestKind`, `subResource`, and `options`
213 /// are dropped. The carried `uid` is the *user* uid (`userInfo.uid`), matching
214 /// the VAP `request.userInfo.uid` variable, not the request round-trip uid.
215 pub fn to_cel_request(&self) -> kube_cel::AdmissionRequest {
216 kube_cel::AdmissionRequest {
217 operation: match self.operation {
218 Operation::Create => "CREATE",
219 Operation::Update => "UPDATE",
220 Operation::Delete => "DELETE",
221 Operation::Connect => "CONNECT",
222 }
223 .to_owned(),
224 username: self.user_info.username.clone().unwrap_or_default(),
225 uid: self.user_info.uid.clone().unwrap_or_default(),
226 groups: self.user_info.groups.clone().unwrap_or_default(),
227 name: self.name.clone(),
228 namespace: self.namespace.clone().unwrap_or_default(),
229 dry_run: self.dry_run,
230 kind: kube_cel::GroupVersionKind {
231 group: self.kind.group.clone(),
232 version: self.kind.version.clone(),
233 kind: self.kind.kind.clone(),
234 },
235 resource: kube_cel::GroupVersionResource {
236 group: self.resource.group.clone(),
237 version: self.resource.version.clone(),
238 resource: self.resource.resource.clone(),
239 },
240 }
241 }
242}
243
244/// An outgoing [`AdmissionReview`] response. Constructed from the corresponding
245/// [`AdmissionRequest`].
246/// ```no_run
247/// use kube::core::{
248/// admission::{AdmissionRequest, AdmissionResponse, AdmissionReview},
249/// DynamicObject,
250/// };
251///
252/// // The incoming AdmissionReview received by the controller.
253/// let body: AdmissionReview<DynamicObject> = todo!();
254/// let req: AdmissionRequest<_> = body.try_into().unwrap();
255///
256/// // A normal response with no side effects.
257/// let _: AdmissionReview<_> = AdmissionResponse::from(&req).into_review();
258///
259/// // A response rejecting the admission webhook with a provided reason.
260/// let _: AdmissionReview<_> = AdmissionResponse::from(&req)
261/// .deny("Some rejection reason.")
262/// .into_review();
263///
264/// use json_patch::{AddOperation, Patch, PatchOperation, jsonptr::PointerBuf};
265///
266/// // A response adding a label to the resource.
267/// let _: AdmissionReview<_> = AdmissionResponse::from(&req)
268/// .with_patch(Patch(vec![PatchOperation::Add(AddOperation {
269/// path: PointerBuf::from_tokens(["metadata","labels","my-label"]),
270/// value: serde_json::Value::String("my-value".to_owned()),
271/// })]))
272/// .unwrap()
273/// .into_review();
274///
275/// ```
276#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
277#[serde(rename_all = "camelCase")]
278#[non_exhaustive]
279pub struct AdmissionResponse {
280 /// Copied from the corresponding constructing [`AdmissionRequest`].
281 #[serde(skip)]
282 pub types: TypeMeta,
283 /// Identifier for the individual request/response. This must be copied over
284 /// from the corresponding AdmissionRequest.
285 pub uid: String,
286 /// Indicates whether or not the admission request was permitted.
287 pub allowed: bool,
288 /// Extra details into why an admission request was denied. This field IS NOT
289 /// consulted in any way if "Allowed" is "true".
290 #[serde(rename = "status")]
291 pub result: Status,
292 /// The patch body. Currently we only support "JSONPatch" which implements
293 /// RFC 6902.
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub patch: Option<Vec<u8>>,
296 /// The type of Patch. Currently we only allow "JSONPatch".
297 #[serde(skip_serializing_if = "Option::is_none")]
298 patch_type: Option<PatchType>,
299 /// An unstructured key value map set by remote admission controller (e.g.
300 /// error=image-blacklisted). MutatingAdmissionWebhook and
301 /// ValidatingAdmissionWebhook admission controller will prefix the keys with
302 /// admission webhook name (e.g.
303 /// imagepolicy.example.com/error=image-blacklisted). AuditAnnotations will
304 /// be provided by the admission webhook to add additional context to the
305 /// audit log for this request.
306 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
307 pub audit_annotations: HashMap<String, String>,
308 /// A list of warning messages to return to the requesting API client.
309 /// Warning messages describe a problem the client making the API request
310 /// should correct or be aware of. Limit warnings to 120 characters if
311 /// possible. Warnings over 256 characters and large numbers of warnings may
312 /// be truncated.
313 #[serde(skip_serializing_if = "Option::is_none")]
314 pub warnings: Option<Vec<String>>,
315}
316
317impl<T: Resource> From<&AdmissionRequest<T>> for AdmissionResponse {
318 fn from(req: &AdmissionRequest<T>) -> Self {
319 Self {
320 types: req.types.clone(),
321 uid: req.uid.clone(),
322 allowed: true,
323 result: Default::default(),
324 patch: None,
325 patch_type: None,
326 audit_annotations: Default::default(),
327 warnings: None,
328 }
329 }
330}
331
332impl AdmissionResponse {
333 /// Constructs an invalid [`AdmissionResponse`]. It doesn't copy the uid from
334 /// the corresponding [`AdmissionRequest`], so should only be used when the
335 /// original request cannot be read.
336 pub fn invalid<T: ToString>(reason: T) -> Self {
337 Self {
338 // Since we don't have a request to use for construction, just
339 // default to "admission.k8s.io/v1beta1", since it is the most
340 // supported and we won't be using any of the new fields.
341 types: TypeMeta {
342 kind: META_KIND.to_owned(),
343 api_version: META_API_VERSION_V1.to_owned(),
344 },
345 uid: Default::default(),
346 allowed: false,
347 result: Status::failure(&reason.to_string(), "InvalidRequest"),
348 patch: None,
349 patch_type: None,
350 audit_annotations: Default::default(),
351 warnings: None,
352 }
353 }
354
355 /// Deny the request with a reason. The reason will be sent to the original caller.
356 #[must_use]
357 pub fn deny<T: ToString>(mut self, reason: T) -> Self {
358 self.allowed = false;
359 self.result.message = reason.to_string();
360 self
361 }
362
363 /// Add JSON patches to the response, modifying the object from the request.
364 pub fn with_patch(mut self, patch: json_patch::Patch) -> Result<Self, SerializePatchError> {
365 self.patch = Some(serde_json::to_vec(&patch).map_err(SerializePatchError)?);
366 self.patch_type = Some(PatchType::JsonPatch);
367
368 Ok(self)
369 }
370
371 /// Converts an [`AdmissionResponse`] into a generic [`AdmissionReview`] that
372 /// can be used as a webhook response.
373 pub fn into_review(self) -> AdmissionReview<DynamicObject> {
374 AdmissionReview {
375 types: self.types.clone(),
376 request: None,
377 response: Some(self),
378 }
379 }
380}
381
382/// The type of patch returned in an [`AdmissionResponse`].
383#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
384pub enum PatchType {
385 /// Specifies the patch body implements JSON Patch under RFC 6902.
386 #[serde(rename = "JSONPatch")]
387 JsonPatch,
388}
389
390#[cfg(test)]
391mod test {
392 const WEBHOOK_BODY: &str = r#"{"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1","request":{"uid":"0c9a8d74-9cb7-44dd-b98e-09fd62def2f4","kind":{"group":"","version":"v1","kind":"Pod"},"resource":{"group":"","version":"v1","resource":"pods"},"requestKind":{"group":"","version":"v1","kind":"Pod"},"requestResource":{"group":"","version":"v1","resource":"pods"},"name":"echo-pod","namespace":"colin-coder","operation":"CREATE","userInfo":{"username":"colin@coder.com","groups":["system:authenticated"],"extra":{"iam.gke.io/user-assertion":["REDACTED"],"user-assertion.cloud.google.com":["REDACTED"]}},"object":{"kind":"Pod","apiVersion":"v1","metadata":{"name":"echo-pod","namespace":"colin-coder","creationTimestamp":null,"labels":{"app":"echo-server"},"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"kind\":\"Pod\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"echo-server\"},\"name\":\"echo-pod\",\"namespace\":\"colin-coder\"},\"spec\":{\"containers\":[{\"image\":\"jmalloc/echo-server\",\"name\":\"echo-server\",\"ports\":[{\"containerPort\":8080,\"name\":\"http-port\"}]}]}}\n"},"managedFields":[{"manager":"kubectl","operation":"Update","apiVersion":"v1","time":"2021-03-29T23:02:16Z","fieldsType":"FieldsV1","fieldsV1":{"f:metadata":{"f:annotations":{".":{},"f:kubectl.kubernetes.io/last-applied-configuration":{}},"f:labels":{".":{},"f:app":{}}},"f:spec":{"f:containers":{"k:{\"name\":\"echo-server\"}":{".":{},"f:image":{},"f:imagePullPolicy":{},"f:name":{},"f:ports":{".":{},"k:{\"containerPort\":8080,\"protocol\":\"TCP\"}":{".":{},"f:containerPort":{},"f:name":{},"f:protocol":{}}},"f:resources":{},"f:terminationMessagePath":{},"f:terminationMessagePolicy":{}}},"f:dnsPolicy":{},"f:enableServiceLinks":{},"f:restartPolicy":{},"f:schedulerName":{},"f:securityContext":{},"f:terminationGracePeriodSeconds":{}}}}]},"spec":{"volumes":[{"name":"default-token-rxbqq","secret":{"secretName":"default-token-rxbqq"}}],"containers":[{"name":"echo-server","image":"jmalloc/echo-server","ports":[{"name":"http-port","containerPort":8080,"protocol":"TCP"}],"resources":{},"volumeMounts":[{"name":"default-token-rxbqq","readOnly":true,"mountPath":"/var/run/secrets/kubernetes.io/serviceaccount"}],"terminationMessagePath":"/dev/termination-log","terminationMessagePolicy":"File","imagePullPolicy":"Always"}],"restartPolicy":"Always","terminationGracePeriodSeconds":30,"dnsPolicy":"ClusterFirst","serviceAccountName":"default","serviceAccount":"default","securityContext":{},"schedulerName":"default-scheduler","tolerations":[{"key":"node.kubernetes.io/not-ready","operator":"Exists","effect":"NoExecute","tolerationSeconds":300},{"key":"node.kubernetes.io/unreachable","operator":"Exists","effect":"NoExecute","tolerationSeconds":300}],"priority":0,"enableServiceLinks":true},"status":{}},"oldObject":null,"dryRun":false,"options":{"kind":"CreateOptions","apiVersion":"meta.k8s.io/v1"}}}"#;
393
394 use crate::{
395 DynamicObject,
396 admission::{AdmissionResponse, AdmissionReview, ConvertAdmissionReviewError},
397 };
398
399 #[test]
400 fn v1_webhook_unmarshals() {
401 serde_json::from_str::<AdmissionReview<DynamicObject>>(WEBHOOK_BODY).unwrap();
402 }
403
404 #[test]
405 fn version_passes_through() -> Result<(), ConvertAdmissionReviewError> {
406 let rev = serde_json::from_str::<AdmissionReview<DynamicObject>>(WEBHOOK_BODY).unwrap();
407 let rev_typ = rev.types.clone();
408 let res = AdmissionResponse::from(&rev.try_into()?).into_review();
409
410 // Ensure TypeMeta was correctly deserialized.
411 assert_ne!(&rev_typ.api_version, "");
412 // The TypeMeta should be correctly passed through from the incoming
413 // request.
414 assert_eq!(&rev_typ, &res.types);
415 Ok(())
416 }
417
418 #[cfg(feature = "cel")]
419 #[test]
420 fn to_cel_request_projects_fields() {
421 use crate::admission::{AdmissionRequest, Operation};
422 let rev = serde_json::from_str::<AdmissionReview<DynamicObject>>(WEBHOOK_BODY).unwrap();
423 let mut req: AdmissionRequest<DynamicObject> = rev.try_into().unwrap();
424
425 let cel = req.to_cel_request();
426 assert_eq!(cel.operation, "CREATE");
427 assert_eq!(cel.name, "echo-pod");
428 assert_eq!(cel.namespace, "colin-coder");
429 assert_eq!(cel.username, "colin@coder.com");
430 assert_eq!(cel.groups, vec!["system:authenticated".to_string()]);
431 assert_eq!(cel.kind.group, "");
432 assert_eq!(cel.kind.version, "v1");
433 assert_eq!(cel.kind.kind, "Pod");
434 assert_eq!(cel.resource.resource, "pods");
435 assert!(!cel.dry_run);
436
437 // `uid` is the *user* uid (`userInfo.uid`), never the round-trip request uid.
438 assert_ne!(req.uid, ""); // sanity: round-trip uid is populated in the payload
439 assert_eq!(cel.uid, ""); // userInfo.uid is absent -> empty, NOT req.uid
440 req.user_info.uid = Some("user-123".to_owned());
441 assert_eq!(req.to_cel_request().uid, "user-123");
442
443 // every Operation variant maps to its SCREAMING_SNAKE form
444 for (op, expected) in [
445 (Operation::Create, "CREATE"),
446 (Operation::Update, "UPDATE"),
447 (Operation::Delete, "DELETE"),
448 (Operation::Connect, "CONNECT"),
449 ] {
450 req.operation = op;
451 assert_eq!(req.to_cel_request().operation, expected);
452 }
453
454 // dry_run passes through
455 req.dry_run = true;
456 assert!(req.to_cel_request().dry_run);
457 }
458}