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    dynamic::DynamicObject,
10    gvk::{GroupVersionKind, GroupVersionResource},
11    metadata::TypeMeta,
12    resource::Resource,
13    Status,
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")]
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/// The `api_version` field in [`TypeMeta`] on the v1beta1 version.
37pub const META_API_VERSION_V1BETA1: &str = "admission.k8s.io/v1beta1";
38
39/// The top level struct used for Serializing and Deserializing AdmissionReview
40/// requests and responses.
41///
42/// This is both the input type received by admission controllers, and the
43/// output type admission controllers should return.
44///
45/// An admission controller should start by inspecting the [`AdmissionRequest`].
46#[derive(Serialize, Deserialize, Clone, Debug)]
47#[serde(rename_all = "camelCase")]
48pub struct AdmissionReview<T: Resource> {
49    /// Contains the API version and type of the request.
50    #[serde(flatten)]
51    pub types: TypeMeta,
52    /// Describes the attributes for the admission request.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub request: Option<AdmissionRequest<T>>,
55    /// Describes the attributes for the admission response.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    #[serde(default)]
58    pub response: Option<AdmissionResponse>,
59}
60
61impl<T: Resource> TryInto<AdmissionRequest<T>> for AdmissionReview<T> {
62    type Error = ConvertAdmissionReviewError;
63
64    fn try_into(self) -> Result<AdmissionRequest<T>, Self::Error> {
65        match self.request {
66            Some(mut req) => {
67                req.types = self.types;
68                Ok(req)
69            }
70            None => Err(ConvertAdmissionReviewError),
71        }
72    }
73}
74
75/// An incoming [`AdmissionReview`] request.
76///
77/// In an admission controller scenario, this is extracted from an [`AdmissionReview`] via [`TryInto`]
78///
79/// ```no_run
80/// use kube::core::{admission::{AdmissionRequest, AdmissionReview}, DynamicObject};
81///
82/// // The incoming AdmissionReview received by the controller.
83/// let body: AdmissionReview<DynamicObject> = todo!();
84/// let req: AdmissionRequest<_> = body.try_into().unwrap();
85/// ```
86///
87/// Based on the contents of the request, an admission controller should construct an
88/// [`AdmissionResponse`] using:
89///
90/// - [`AdmissionResponse::deny`] for illegal/rejected requests
91/// - [`AdmissionResponse::invalid`] for malformed requests
92/// - [`AdmissionResponse::from`] for the happy path
93///
94/// then wrap the chosen response in an [`AdmissionReview`] via [`AdmissionResponse::into_review`].
95#[derive(Serialize, Deserialize, Clone, Debug)]
96#[serde(rename_all = "camelCase")]
97pub struct AdmissionRequest<T: Resource> {
98    /// Copied from the containing [`AdmissionReview`] and used to specify a
99    /// response type and version when constructing an [`AdmissionResponse`].
100    #[serde(skip)]
101    pub types: TypeMeta,
102    /// An identifier for the individual request/response. It allows us to
103    /// distinguish instances of requests which are otherwise identical (parallel
104    /// requests, requests when earlier requests did not modify, etc). The UID is
105    /// meant to track the round trip (request/response) between the KAS and the
106    /// webhook, not the user request. It is suitable for correlating log entries
107    /// between the webhook and apiserver, for either auditing or debugging.
108    pub uid: String,
109    /// The fully-qualified type of object being submitted (for example, v1.Pod
110    /// or autoscaling.v1.Scale).
111    pub kind: GroupVersionKind,
112    /// The fully-qualified resource being requested (for example, v1.pods).
113    pub resource: GroupVersionResource,
114    /// The subresource being requested, if any (for example, "status" or
115    /// "scale").
116    #[serde(default)]
117    pub sub_resource: Option<String>,
118    /// The fully-qualified type of the original API request (for example, v1.Pod
119    /// or autoscaling.v1.Scale). If this is specified and differs from the value
120    /// in "kind", an equivalent match and conversion was performed.
121    ///
122    /// For example, if deployments can be modified via apps/v1 and apps/v1beta1,
123    /// and a webhook registered a rule of `apiGroups:["apps"],
124    /// apiVersions:["v1"], resources:["deployments"]` and
125    /// `matchPolicy:Equivalent`, an API request to apps/v1beta1 deployments
126    /// would be converted and sent to the webhook with `kind: {group:"apps",
127    /// version:"v1", kind:"Deployment"}` (matching the rule the webhook
128    /// registered for), and `requestKind: {group:"apps", version:"v1beta1",
129    /// kind:"Deployment"}` (indicating the kind of the original API request).
130    /// See documentation for the "matchPolicy" field in the webhook
131    /// configuration type for more details.
132    #[serde(default)]
133    pub request_kind: Option<GroupVersionKind>,
134    /// The fully-qualified resource of the original API request (for example,
135    /// v1.pods). If this is specified and differs from the value in "resource",
136    /// an equivalent match and conversion was performed.
137    ///
138    /// For example, if deployments can be modified via apps/v1 and apps/v1beta1,
139    /// and a webhook registered a rule of `apiGroups:["apps"],
140    /// apiVersions:["v1"], resources: ["deployments"]` and `matchPolicy:
141    /// Equivalent`, an API request to apps/v1beta1 deployments would be
142    /// converted and sent to the webhook with `resource: {group:"apps",
143    /// version:"v1", resource:"deployments"}` (matching the resource the webhook
144    /// registered for), and `requestResource: {group:"apps", version:"v1beta1",
145    /// resource:"deployments"}` (indicating the resource of the original API
146    /// request).
147    ///
148    /// See documentation for the "matchPolicy" field in the webhook
149    /// configuration type.
150    #[serde(default)]
151    pub request_resource: Option<GroupVersionResource>,
152    /// The name of the subresource of the original API request, if any (for
153    /// example, "status" or "scale"). If this is specified and differs from the
154    /// value in "subResource", an equivalent match and conversion was performed.
155    /// See documentation for the "matchPolicy" field in the webhook
156    /// configuration type.
157    #[serde(default)]
158    pub request_sub_resource: Option<String>,
159    /// The name of the object as presented in the request. On a CREATE
160    /// operation, the client may omit name and rely on the server to generate
161    /// the name. If that is the case, this field will contain an empty string.
162    #[serde(default)]
163    pub name: String,
164    /// The namespace associated with the request (if any).
165    #[serde(default)]
166    pub namespace: Option<String>,
167    /// The operation being performed. This may be different than the operation
168    /// requested. e.g. a patch can result in either a CREATE or UPDATE
169    /// Operation.
170    pub operation: Operation,
171    /// Information about the requesting user.
172    pub user_info: UserInfo,
173    /// The object from the incoming request. It's `None` for [`DELETE`](Operation::Delete) operations.
174    pub object: Option<T>,
175    ///  The existing object. Only populated for DELETE and UPDATE requests.
176    pub old_object: Option<T>,
177    /// Specifies that modifications will definitely not be persisted for this
178    /// request.
179    #[serde(default)]
180    pub dry_run: bool,
181    /// The operation option structure of the operation being performed. e.g.
182    /// `meta.k8s.io/v1.DeleteOptions` or `meta.k8s.io/v1.CreateOptions`. This
183    /// may be different than the options the caller provided. e.g. for a patch
184    /// request the performed [`Operation`] might be a [`CREATE`](Operation::Create), in
185    /// which case the Options will a `meta.k8s.io/v1.CreateOptions` even though
186    /// the caller provided `meta.k8s.io/v1.PatchOptions`.
187    #[serde(default)]
188    pub options: Option<RawExtension>,
189}
190
191/// The operation specified in an [`AdmissionRequest`].
192#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
193#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
194pub enum Operation {
195    /// An operation that creates a resource.
196    Create,
197    /// An operation that updates a resource.
198    Update,
199    /// An operation that deletes a resource.
200    Delete,
201    /// An operation that connects to a resource.
202    Connect,
203}
204
205/// An outgoing [`AdmissionReview`] response. Constructed from the corresponding
206/// [`AdmissionRequest`].
207/// ```no_run
208/// use kube::core::{
209///     admission::{AdmissionRequest, AdmissionResponse, AdmissionReview},
210///     DynamicObject,
211/// };
212///
213/// // The incoming AdmissionReview received by the controller.
214/// let body: AdmissionReview<DynamicObject> = todo!();
215/// let req: AdmissionRequest<_> = body.try_into().unwrap();
216///
217/// // A normal response with no side effects.
218/// let _: AdmissionReview<_> = AdmissionResponse::from(&req).into_review();
219///
220/// // A response rejecting the admission webhook with a provided reason.
221/// let _: AdmissionReview<_> = AdmissionResponse::from(&req)
222///     .deny("Some rejection reason.")
223///     .into_review();
224///
225/// use json_patch::{AddOperation, Patch, PatchOperation};
226/// use jsonptr::PointerBuf;
227///
228/// // A response adding a label to the resource.
229/// let _: AdmissionReview<_> = AdmissionResponse::from(&req)
230///     .with_patch(Patch(vec![PatchOperation::Add(AddOperation {
231///         path: PointerBuf::from_tokens(["metadata","labels","my-label"]),
232///         value: serde_json::Value::String("my-value".to_owned()),
233///     })]))
234///     .unwrap()
235///     .into_review();
236///
237/// ```
238#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
239#[serde(rename_all = "camelCase")]
240#[non_exhaustive]
241pub struct AdmissionResponse {
242    /// Copied from the corresponding consructing [`AdmissionRequest`].
243    #[serde(skip)]
244    pub types: TypeMeta,
245    /// Identifier for the individual request/response. This must be copied over
246    /// from the corresponding AdmissionRequest.
247    pub uid: String,
248    /// Indicates whether or not the admission request was permitted.
249    pub allowed: bool,
250    /// Extra details into why an admission request was denied. This field IS NOT
251    /// consulted in any way if "Allowed" is "true".
252    #[serde(rename = "status")]
253    pub result: Status,
254    /// The patch body. Currently we only support "JSONPatch" which implements
255    /// RFC 6902.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub patch: Option<Vec<u8>>,
258    /// The type of Patch. Currently we only allow "JSONPatch".
259    #[serde(skip_serializing_if = "Option::is_none")]
260    patch_type: Option<PatchType>,
261    /// An unstructured key value map set by remote admission controller (e.g.
262    /// error=image-blacklisted). MutatingAdmissionWebhook and
263    /// ValidatingAdmissionWebhook admission controller will prefix the keys with
264    /// admission webhook name (e.g.
265    /// imagepolicy.example.com/error=image-blacklisted). AuditAnnotations will
266    /// be provided by the admission webhook to add additional context to the
267    /// audit log for this request.
268    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
269    pub audit_annotations: HashMap<String, String>,
270    /// A list of warning messages to return to the requesting API client.
271    /// Warning messages describe a problem the client making the API request
272    /// should correct or be aware of. Limit warnings to 120 characters if
273    /// possible. Warnings over 256 characters and large numbers of warnings may
274    /// be truncated.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub warnings: Option<Vec<String>>,
277}
278
279impl<T: Resource> From<&AdmissionRequest<T>> for AdmissionResponse {
280    fn from(req: &AdmissionRequest<T>) -> Self {
281        Self {
282            types: req.types.clone(),
283            uid: req.uid.clone(),
284            allowed: true,
285            result: Default::default(),
286            patch: None,
287            patch_type: None,
288            audit_annotations: Default::default(),
289            warnings: None,
290        }
291    }
292}
293
294impl AdmissionResponse {
295    /// Constructs an invalid [`AdmissionResponse`]. It doesn't copy the uid from
296    /// the corresponding [`AdmissionRequest`], so should only be used when the
297    /// original request cannot be read.
298    pub fn invalid<T: ToString>(reason: T) -> Self {
299        Self {
300            // Since we don't have a request to use for construction, just
301            // default to "admission.k8s.io/v1beta1", since it is the most
302            // supported and we won't be using any of the new fields.
303            types: TypeMeta {
304                kind: META_KIND.to_owned(),
305                api_version: META_API_VERSION_V1BETA1.to_owned(),
306            },
307            uid: Default::default(),
308            allowed: false,
309            result: Status::failure(&reason.to_string(), "InvalidRequest"),
310            patch: None,
311            patch_type: None,
312            audit_annotations: Default::default(),
313            warnings: None,
314        }
315    }
316
317    /// Deny the request with a reason. The reason will be sent to the original caller.
318    #[must_use]
319    pub fn deny<T: ToString>(mut self, reason: T) -> Self {
320        self.allowed = false;
321        self.result.message = reason.to_string();
322        self
323    }
324
325    /// Add JSON patches to the response, modifying the object from the request.
326    pub fn with_patch(mut self, patch: json_patch::Patch) -> Result<Self, SerializePatchError> {
327        self.patch = Some(serde_json::to_vec(&patch).map_err(SerializePatchError)?);
328        self.patch_type = Some(PatchType::JsonPatch);
329
330        Ok(self)
331    }
332
333    /// Converts an [`AdmissionResponse`] into a generic [`AdmissionReview`] that
334    /// can be used as a webhook response.
335    pub fn into_review(self) -> AdmissionReview<DynamicObject> {
336        AdmissionReview {
337            types: self.types.clone(),
338            request: None,
339            response: Some(self),
340        }
341    }
342}
343
344/// The type of patch returned in an [`AdmissionResponse`].
345#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
346pub enum PatchType {
347    /// Specifies the patch body implements JSON Patch under RFC 6902.
348    #[serde(rename = "JSONPatch")]
349    JsonPatch,
350}
351
352#[cfg(test)]
353mod test {
354    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"}}}"#;
355
356    use crate::{
357        admission::{AdmissionResponse, AdmissionReview, ConvertAdmissionReviewError},
358        DynamicObject,
359    };
360
361    #[test]
362    fn v1_webhook_unmarshals() {
363        serde_json::from_str::<AdmissionReview<DynamicObject>>(WEBHOOK_BODY).unwrap();
364    }
365
366    #[test]
367    fn version_passes_through() -> Result<(), ConvertAdmissionReviewError> {
368        let rev = serde_json::from_str::<AdmissionReview<DynamicObject>>(WEBHOOK_BODY).unwrap();
369        let rev_typ = rev.types.clone();
370        let res = AdmissionResponse::from(&rev.try_into()?).into_review();
371
372        // Ensure TypeMeta was correctly deserialized.
373        assert_ne!(&rev_typ.api_version, "");
374        // The TypeMeta should be correctly passed through from the incoming
375        // request.
376        assert_eq!(&rev_typ, &res.types);
377        Ok(())
378    }
379}