Skip to content

Commit ec0066d

Browse files
authored
SREP-2251: Add webhook validation framework (#1)
* SREP-2251: Add webhook validation framework Provides a method for adding webhooks and injecting generated caBundles for use by said webhook. Refer to the README.md for additional details on how to add new hooks. The included hook addresses the group validation request for SREP-2251. Forthcoming changes will address CI/CD concerns. Signed-off-by: Lisa Seelye <lseelye@redhat.com>
1 parent a67786d commit ec0066d

22 files changed

+599
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/deploy
2+
/__pycache__

Makefile

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
SHELL := /usr/bin/env bash
2+
3+
TEMPLATEFILES := $(shell find ./templates -type f -name "*.yaml.tmpl")
4+
5+
NAMESPACE ?= openshift-validation-webhook
6+
SVCNAME ?= validation-webhook
7+
SANAME ?= validation-webhook
8+
IMAGETAG ?= latest
9+
CABUNDLECONFIGMAP ?= webhook-cert
10+
VWC_ANNOTATION ?= managed.openshift.io/inject-cabundle-from
11+
12+
IMG ?= quay.io/lseelye/python3-webhookbase
13+
14+
default: all
15+
all: build-base render
16+
17+
.PHONY: build-base
18+
build-base: build/Dockerfile
19+
docker build -t $(IMG):$(IMAGETAG) -f build/Dockerfile . && docker push $(IMG):$(IMAGETAG)
20+
21+
# TODO: Change the render to allow for the permissions to have a list of all the webhook names
22+
# TODO: Pull that list of names from the yaml files?
23+
render: $(TEMPLATEFILES) build/Dockerfile
24+
for f in $(TEMPLATEFILES); do \
25+
sed \
26+
-e "s!\#NAMESPACE\#!$(NAMESPACE)!g" \
27+
-e "s!\#SVCNAME\#!$(SVCNAME)!g" \
28+
-e "s!\#SANAME\#!$(SANAME)!g" \
29+
-e "s!\#IMAGETAG\#!$(IMAGETAG)!g" \
30+
-e "s!\#IMG\#!$(IMG)!g" \
31+
-e "s!\#CABUNDLECONFIGMAP\#!$(CABUNDLECONFIGMAP)!g" \
32+
-e "s!\#VWC_ANNOTATION\#!$(VWC_ANNOTATION)!g" \
33+
$$f > deploy/$$(basename $$f .tmpl) ;\
34+
done

README.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Managed Cluster Validating Webhooks
2+
3+
A Flask app designed to act as a webhook admission controller for OpenShift.
4+
5+
Presently there is a single webhook, [group-validation](#group_validation), which is provided via `/group-validation` endpoint.
6+
7+
## Group Validation
8+
9+
Configuration for this webhook is provided by environment variables:
10+
11+
* `GROUP_VALIDATION_PREFIX` - Group prefix to apply the webhook, such as `osd-` to apply to `CREATE`, `UPDATE`, `DELETE` operations on groups starting with `osd-`.
12+
* `GROUP_VALIDATION_ADMIN_GROUP` - Admin group, which the requestor must be a member in order to have access granted.
13+
* `DEBUG_GROUP_VALIDATION` - Debug the webhook? Set to `True` to enable, all other values (including absent) disable.
14+
15+
## How it works
16+
17+
In order for a validating webhook to talk to the code which is performing the validation (eg, the code in this repository), which is running in-cluster, Kubernetes needs to talk to it via a `Service` over HTTPS. This forces the Python Flask app to serve itself with a TLS certificate and the corresponding webhook configuration to specify the CA Bundle (`caBundle`) that matches up for those TLS certs.
18+
19+
The TLS cert is provisioned by using the [openshift-ca-operator](https://github.com/openshift/service-ca-operator). Refer to its documentation for how TLS keys are requested and stored. See also: [02-webhook-cacert.configmap.yaml.tmpl](/templates/02-webhook-cacert.configmap.yaml.tmpl) and [05-group-validation-webhook.service.yaml.tmpl](/templates/05-group-validation-webhook.service.yaml.tmpl).
20+
21+
Getting the TLS certificates is only part of the battle, as the operator does not inject them into the `ValidatingWebhookConfiguration`. To accomplish that, a small Python script has been written that is used as an `initContainer` in the Deployment of the webhook framework. The "injector" script, when run, will find all `ValidatingWebhookConfiguration` objects with an `managed.openshift.io/inject-cabundle-from` annotation. The annotation's value is in the format `namespace/configmap` from whence the CA Bundle can be found (as the key `service-ca.crt`). Thus an annotation `managed.openshift.io/inject-cabundle-from: openshift-validation-webhook/webhook-cert` will have the "injector" script look in the `openshift-validation-webhook` `Namespace` for the `webhook-cert` `ConfigMap` to contain a `service-ca.crt` key and therein, a PEM encoded certificate. The certificate is base64-encoded and set as the `caBundle` for each webhook defined in the `ValidatingWebhookConfiguration`.
22+
23+
## Development
24+
25+
### Adding New Webhooks
26+
27+
In order to add new webhooks, create a new Python file in [src/webhook](src/webhook), following the pattern from [src/webhook/group_validation.py](src/webhook/group_validation.py). Add an entry to [src/webhook/__init__.py](src/webhook/__init__.py) in the pattern of the group validation webhook.
28+
29+
#### Register with the Flask application
30+
31+
To register your webhook with the Flask app:
32+
33+
```python
34+
# src/webhook/__init__.py
35+
from flask import Flask
36+
from flask import request
37+
38+
app = Flask(__name__,instance_relative_config=True)
39+
40+
from webhook import group_validation
41+
app.register_blueprint(group_validation.bp)
42+
43+
from webhook import your_hook
44+
app.register_blueprint(your_hook.bp)
45+
```
46+
47+
#### Adding YAML Manifests
48+
49+
To add a new YAML Manifest:
50+
51+
Create a new file in [templates](/templates) directory with a `10-` prefix, ex `10-your-hook.ValidatingWebhookConfiguration.yaml.tmpl` with contents:
52+
53+
```yaml
54+
apiVersion: admissionregistration.k8s.io/v1beta1
55+
kind: ValidatingWebhookConfiguration
56+
metadata:
57+
name: your-webhook-name-here
58+
annotations:
59+
# Typically managed.openshift.io/inject-cabundle-from: namespace/configmap
60+
# The configmap must have the cert in PEM format in a key named service-ca.crt.
61+
# Each webhook in this object with a service clientConfig will have the bundle injected.
62+
#VWC_ANNOTATION#: #NAMESPACE#/#CABUNDLECONFIGMAP#
63+
webhooks:
64+
- clientConfig:
65+
service:
66+
namespace: #NAMESPACE#
67+
name: #SVCNAME#
68+
path: /your-webhook
69+
failurePolicy:
70+
# What to do if the hook itself fails (Ignore/Fail)
71+
name: your-webhook.managed.openshift.io
72+
rules:
73+
- operations:
74+
# operations list
75+
apiGroups:
76+
# apiGroups list
77+
apiVersions:
78+
# apiVersions list
79+
resources:
80+
# resources List
81+
```
82+
83+
From here, `make render` will populate [deploy](/deploy) with YAML manifests that can be `oc apply` to the cluster in question. Note that new hooks require a restart of the Flask application.
84+
85+
### Request Helpers
86+
87+
There are helper methods within the [src/webhook/request_helper](src/webhook/request_helper) to aid with:
88+
89+
* [Incoming request validation](src/webhook/request_helper/validate.py)
90+
* [Formulating the response JSON body](src/webhook/request_helper/responses.py)
91+
92+
To use the request validation:
93+
94+
```python
95+
# src/webhook/your_hook.py
96+
from flask import request, Blueprint
97+
import json
98+
99+
from webhook.request_helper import validate, responses
100+
@bp.route('/your-webhook', methods=('GET','POST'))
101+
def handle_request():
102+
valid = True
103+
try:
104+
valid = validate.validate_request_structure(request.json)
105+
except:
106+
# if anything goes wrong, it's not valid.
107+
valid = False
108+
if not valid:
109+
return responses.response_invalid()
110+
# ... normal hook flow
111+
```
112+
113+
To use the response helpers:
114+
115+
```python
116+
# src/webhook/your_hook.py
117+
from flask import request, Blueprint
118+
import json
119+
120+
from webhook.request_helper import responses
121+
@bp.route('/your-webhook', methods=('GET','POST'))
122+
def handle_request():
123+
# ...
124+
125+
# request is the object coming from the webhook
126+
# request.json converts to JSON document, and the request key therein has the interesting data
127+
request_body = request.json['request']
128+
129+
# Invalid request came in
130+
return responses.response_invalid()
131+
132+
# Access granted:
133+
return responses.response_allow(req=request_body)
134+
135+
# Access denied:
136+
return responses.response_deny(req=response_body, msg="Reason to deny")
137+
138+
# ...
139+
```

build/Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM python:3.7
2+
3+
4+
ADD src /app
5+
WORKDIR /app
6+
RUN pip install -r /app/requirements.txt
7+
ENV FLASK_APP=webhook
8+
EXPOSE 5000
9+
10+
CMD ["gunicorn", "--config", "/app/gunicorn.py"]

deploy/01-webhook.namespace.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
apiVersion: v1
2+
kind: Namespace
3+
metadata:
4+
name: openshift-validation-webhook
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
apiVersion: v1
2+
kind: ConfigMap
3+
metadata:
4+
annotations:
5+
service.beta.openshift.io/inject-cabundle: "true"
6+
name: webhook-cert
7+
namespace: openshift-validation-webhook
8+
# data.(service-ca.crt) is filled in by openshift-ca-operator
9+
data: {}

deploy/02-webhook.permissions.yaml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
apiVersion: rbac.authorization.k8s.io/v1
2+
kind: ClusterRole
3+
metadata:
4+
name: webhook-validation-cr
5+
rules:
6+
- apiGroups:
7+
- admissionregistration.k8s.io
8+
resources:
9+
- validatingwebhookconfigurations
10+
verbs:
11+
- list
12+
- patch
13+
- get
14+
- apiGroups:
15+
- ""
16+
resources:
17+
- configmaps
18+
verbs:
19+
- list
20+
- get
21+
---
22+
apiVersion: v1
23+
kind: ServiceAccount
24+
metadata:
25+
name: validation-webhook
26+
namespace: openshift-validation-webhook
27+
---
28+
apiVersion: rbac.authorization.k8s.io/v1
29+
kind: ClusterRoleBinding
30+
metadata:
31+
name: webhook-validation
32+
roleRef:
33+
apiGroup: rbac.authorization.k8s.io
34+
kind: ClusterRole
35+
name: webhook-validation-cr
36+
subjects:
37+
- kind: ServiceAccount
38+
name: validation-webhook
39+
namespace: openshift-validation-webhook

src/README.md

Whitespace-only changes.

src/gunicorn.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# gunicorn config file
2+
import ssl
3+
4+
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" "pid=%(p)s"'
5+
raw_env = [
6+
'FLASK_APP=webhook',
7+
8+
'GROUP_VALIDATION_ADMIN_GROUP=osd-sre-admins',
9+
'GROUP_VALIDATION_PREFIX=osd-sre-',
10+
]
11+
bind="0.0.0.0:5000"
12+
workers=5
13+
accesslog="-"
14+
#cert_reqs = ssl.CERT_REQUIRED

src/init.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env python3
2+
3+
# Update the ValidatingWebhookConfiguration with the contents of the Service CA.
4+
5+
from kubernetes import client, config
6+
import os
7+
import argparse
8+
import copy
9+
import base64
10+
11+
12+
parser = argparse.ArgumentParser(description="Options to Program")
13+
parser.add_argument('-a', default="managed.openshift.io/inject-cabundle-from", dest='annotation_name', help='What is the annotation that has a reference to a namespace/configmap for the caBundle. The cert must be stored in pem format in a key called service-ca.crt')
14+
parsed = parser.parse_args()
15+
16+
config.load_incluster_config()
17+
admission_client = client.AdmissionregistrationV1beta1Api()
18+
cm_client = client.CoreV1Api()
19+
20+
def get_cert_from_configmap(client, namespace, configmap_name, key="service-ca.crt"):
21+
try:
22+
o = client.read_namespaced_config_map(configmap_name, namespace)
23+
if key in o.data:
24+
return o.data[key].rstrip()
25+
except:
26+
return None
27+
return None
28+
29+
def encode_cert(cert):
30+
return base64.b64encode(cert.encode("UTF-8")).decode("UTF-8")
31+
32+
def get_validating_webhook_configuration_objects_with_annotation(client, annotation):
33+
ret = []
34+
for o in client.list_validating_webhook_configuration().items:
35+
if annotation in o.metadata.annotations:
36+
ret.append(o)
37+
return ret
38+
39+
for vwc in get_validating_webhook_configuration_objects_with_annotation(admission_client, parsed.annotation_name):
40+
ns, cm_name = vwc.metadata.annotations[parsed.annotation_name].split('/')
41+
cert = get_cert_from_configmap(cm_client, ns, cm_name)
42+
if cert is None:
43+
print("WARNING: Skipping validatingwebhookconfiguration/{}: Couldn't find a cert from {}/{} ConfigMap. \n".format(vwc.metadata.name, ns, cm_name))
44+
continue
45+
encoded_cert = encode_cert(cert)
46+
new_vwc = copy.deepcopy(vwc)
47+
for hook in new_vwc.webhooks:
48+
if hook.client_config.service is not None and hook.client_config.ca_bundle is not encoded_cert:
49+
hook.client_config.ca_bundle = encoded_cert
50+
print("validatingwebhookconfiguration/{}: Injecting caBundle from {}/{}, for hook name {}, to service/{}/{}\n".format(new_vwc.metadata.name, ns, cm_name, hook.name, hook.client_config.service.namespace, hook.client_config.service.name))
51+
try:
52+
result = admission_client.patch_validating_webhook_configuration(name=new_vwc.metadata.name, body=new_vwc)
53+
except Exception as err:
54+
print("ERROR: Couldn't save validatingwebhookconfiguration/{}: {}\n",new_vwc.metadata.name, err)
55+
os.exit(1)

0 commit comments

Comments
 (0)