├── .gitignore ├── LICENSE ├── Makefile ├── OWNERS ├── OWNERS_ALIASES ├── README.md ├── build ├── Dockerfile ├── bin │ ├── entrypoint │ └── user_setup ├── build_deploy.sh ├── build_push.sh ├── build_push_package.sh ├── pr_check.sh ├── resources.go └── selectorsyncset.yaml ├── cmd ├── fips.go └── main.go ├── config ├── config.go └── package │ ├── managed-cluster-validating-webhooks-package.Containerfile │ ├── manifest.yaml │ └── resources.yaml.gotmpl ├── designs └── validating_admission_policy.md ├── docs ├── hypershift.md ├── webhooks-short.json └── webhooks.json ├── go.mod ├── go.sum ├── hack ├── documentation │ └── document.go ├── templates │ └── 00-managed-cluster-validating-webhooks-hs.SelectorSyncSet.yaml.tmpl └── test.sh ├── pkg ├── config │ ├── config.go │ ├── generate │ │ └── namespaces.go │ └── namespaces.go ├── dispatcher │ └── dispatcher.go ├── helpers │ ├── response.go │ └── response_test.go ├── k8sutil │ └── k8sutil.go ├── localmetrics │ └── localmetrics.go ├── syncset │ └── syncsetbylabelselector.go ├── testutils │ └── testutils.go └── webhooks │ ├── add_clusterlogging.go │ ├── add_clusterrolebinding.go │ ├── add_customresourcedefinitions.go │ ├── add_hiveownership.go │ ├── add_imagecontentpolicies.go │ ├── add_ingressconfig_hook.go │ ├── add_ingresscontroller.go │ ├── add_namespace_hook.go │ ├── add_networkpolicy.go │ ├── add_node.go │ ├── add_pod.go │ ├── add_podimagespec.go │ ├── add_prometheusrule.go │ ├── add_regularuser.go │ ├── add_scc.go │ ├── add_sdnmigration.go │ ├── add_service_hook.go │ ├── add_serviceaccount.go │ ├── add_techpreviewnoupgrade.go │ ├── clusterlogging │ ├── clusterlogging.go │ └── clusterlogging_test.go │ ├── clusterrolebinding │ ├── clusterrolebinding.go │ └── clusterrolebinding_test.go │ ├── customresourcedefinitions │ ├── customresourcedefinitions.go │ └── customresourcedefinitions_test.go │ ├── hiveownership │ ├── hiveownership.go │ └── hiveownership_test.go │ ├── imagecontentpolicies │ ├── imagecontentpolicies.go │ └── imagecontentpolicies_test.go │ ├── ingressconfig │ ├── ingressconfig.go │ └── ingressconfig_test.go │ ├── ingresscontroller │ ├── ingresscontroller.go │ └── ingresscontroller_test.go │ ├── namespace │ ├── namespace.go │ └── namespace_test.go │ ├── networkpolicies │ ├── networkpolicies.go │ └── networkpolicies_test.go │ ├── node │ ├── node.go │ └── node_test.go │ ├── pod │ ├── pod.go │ └── pod_test.go │ ├── podimagespec │ ├── podimagespec.go │ └── podimagespec_test.go │ ├── prometheusrule │ ├── prometheusrule.go │ └── prometheusrule_test.go │ ├── register.go │ ├── regularuser │ └── common │ │ ├── regularuser.go │ │ └── regularuser_test.go │ ├── scc │ ├── scc.go │ └── scc_test.go │ ├── sdnmigration │ ├── sdnmigration.go │ └── sdnmigration_test.go │ ├── service │ ├── service.go │ └── service_test.go │ ├── serviceaccount │ ├── serviceaccount.go │ └── serviceaccount_test.go │ ├── techpreviewnoupgrade │ ├── techpreviewnoupgrade.go │ └── techpreviewnoupgrade_test.go │ └── utils │ ├── utils.go │ └── utils_test.go └── test └── e2e ├── Dockerfile ├── managed_cluster_validating_webhooks_runner_test.go ├── managed_cluster_validating_webhooks_test.go ├── project.mk └── test-harness-template.yml /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | /build/_output 3 | *.out 4 | /coverage.txt 5 | /.vscode 6 | *.code-workspace 7 | .idea 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /usr/bin/env bash 2 | 3 | include test/e2e/project.mk 4 | # Verbosity 5 | AT_ = @ 6 | AT = $(AT_$(V)) 7 | # /Verbosity 8 | 9 | GIT_HASH := $(shell git rev-parse --short=7 HEAD) 10 | IMAGETAG ?= ${GIT_HASH} 11 | 12 | BASE_IMG ?= managed-cluster-validating-webhooks 13 | BASE_PKG_IMG ?= managed-cluster-validating-webhooks-hs-package 14 | IMG_REGISTRY ?= quay.io 15 | IMG_ORG ?= app-sre 16 | IMG ?= $(IMG_REGISTRY)/$(IMG_ORG)/${BASE_IMG} 17 | PKG_IMG ?= $(IMG_REGISTRY)/$(IMG_ORG)/${BASE_PKG_IMG} 18 | 19 | SYNCSET_GENERATOR_IMAGE := registry.ci.openshift.org/openshift/release:golang-1.21 20 | 21 | BINARY_FILE ?= build/_output/webhooks 22 | 23 | GO_SOURCES := $(find $(CURDIR) -type f -name "*.go" -print) 24 | EXTRA_DEPS := $(find $(CURDIR)/build -type f -print) Makefile 25 | 26 | # Containers may default GOFLAGS=-mod=vendor which would break us since 27 | # we're using modules. 28 | unexport GOFLAGS 29 | GOOS?=linux 30 | GOARCH?=amd64 31 | GOFLAGS_MOD?=-mod=mod 32 | GOENV=GOOS=${GOOS} GOARCH=${GOARCH} CGO_ENABLED=1 GOEXPERIMENT=boringcrypto GOFLAGS=${GOFLAGS_MOD} 33 | 34 | GOBUILDFLAGS=-gcflags="all=-trimpath=${GOPATH}" -asmflags="all=-trimpath=${GOPATH}" -tags="fips_enabled" 35 | 36 | # do not include this comma-separated list of hooks into the syncset 37 | SELECTOR_SYNC_SET_HOOK_EXCLUDES ?= debug-hook 38 | SELECTOR_SYNC_SET_DESTINATION = build/selectorsyncset.yaml 39 | 40 | PACKAGE_RESOURCE_DESTINATION = config/package/resources.yaml.gotmpl 41 | PACKAGE_RESOURCE_MANIFEST = config/package/manifest.yaml 42 | 43 | CONTAINER_ENGINE ?= $(shell command -v podman 2>/dev/null || command -v docker 2>/dev/null) 44 | #eg, -v 45 | TESTOPTS ?= 46 | 47 | DOC_BINARY := hack/documentation/document.go 48 | # ex -hideRules 49 | DOCFLAGS ?= 50 | 51 | default: all 52 | 53 | all: test build-image build-package-image build-sss 54 | 55 | .PHONY: test 56 | test: vet $(GO_SOURCES) 57 | $(AT)go test $(TESTOPTS) $(shell go list -mod=readonly -e ./...) 58 | $(AT)go run cmd/main.go -testhooks 59 | 60 | .PHONY: clean 61 | clean: 62 | $(AT)rm -f $(BINARY_FILE) coverage.txt 63 | 64 | .PHONY: serve 65 | serve: 66 | $(AT)go run ./cmd/main.go -port 8888 67 | 68 | .PHONY: vet 69 | vet: 70 | $(AT)go fmt ./... 71 | $(AT)go vet ./cmd/... ./pkg/... 72 | 73 | .PHONY: generate 74 | generate: 75 | $(AT)go generate ./pkg/config 76 | 77 | .PHONY: build 78 | build: $(BINARY_FILE) 79 | 80 | $(BINARY_FILE): test $(GO_SOURCES) 81 | mkdir -p $(shell dirname $(BINARY_FILE)) 82 | $(GOENV) go build $(GOBUILDFLAGS) -o $(BINARY_FILE) ./cmd 83 | 84 | .PHONY: build-base 85 | build-base: build-image build-package-image 86 | .PHONY: build-image 87 | build-image: clean $(GO_SOURCES) $(EXTRA_DEPS) 88 | $(CONTAINER_ENGINE) build --platform=linux/amd64 -t $(IMG):$(IMAGETAG) -f $(join $(CURDIR),/build/Dockerfile) . && \ 89 | $(CONTAINER_ENGINE) tag $(IMG):$(IMAGETAG) $(IMG):latest 90 | 91 | .PHONY: build-package-image 92 | build-package-image: clean $(GO_SOURCES) $(EXTRA_DEPS) 93 | # Change image placeholder in deployment template to the real image 94 | $(shell sed -i -e "s#REPLACED_BY_PIPELINE#$(IMG):$(IMAGETAG)#g" $(PACKAGE_RESOURCE_DESTINATION)) 95 | $(CONTAINER_ENGINE) build --platform=linux/amd64 -t $(PKG_IMG):$(IMAGETAG) -f $(join $(CURDIR),/config/package/managed-cluster-validating-webhooks-package.Containerfile) . && \ 96 | $(CONTAINER_ENGINE) tag $(PKG_IMG):$(IMAGETAG) $(PKG_IMG):latest 97 | # Restore the template file modified for the package build 98 | git checkout $(PACKAGE_RESOURCE_DESTINATION) 99 | 100 | .PHONY: build-push 101 | build-push: 102 | build/build_push.sh $(IMG):$(IMAGETAG) 103 | 104 | .PHONY: build-push-package 105 | build-push-package: 106 | build/build_push_package.sh $(PKG_IMG):$(IMAGETAG) 107 | 108 | build-sss: syncset 109 | render: syncset 110 | .PHONY: syncset $(SELECTOR_SYNC_SET_DESTINATION) 111 | syncset: $(SELECTOR_SYNC_SET_DESTINATION) 112 | $(SELECTOR_SYNC_SET_DESTINATION): 113 | $(CONTAINER_ENGINE) run \ 114 | -v $(CURDIR):$(CURDIR):z \ 115 | -w $(CURDIR) \ 116 | -e GOFLAGS=$(GOFLAGS) \ 117 | --rm \ 118 | $(SYNCSET_GENERATOR_IMAGE) \ 119 | go run \ 120 | build/resources.go \ 121 | -exclude $(SELECTOR_SYNC_SET_HOOK_EXCLUDES) \ 122 | -syncsetfile $(@) 123 | 124 | render: package 125 | .PHONY: package $(PACKAGE_RESOURCE_DESTINATION) 126 | package: $(PACKAGE_RESOURCE_DESTINATION) $(PACKAGE_RESOURCE_MANIFEST) 127 | $(PACKAGE_RESOURCE_DESTINATION): 128 | mkdir -p $(shell dirname $(PACKAGE_RESOURCE_DESTINATION)) 129 | $(CONTAINER_ENGINE) run \ 130 | -v $(CURDIR):$(CURDIR):z \ 131 | -w $(CURDIR) \ 132 | -e GOFLAGS=$(GOFLAGS) \ 133 | --rm \ 134 | $(SYNCSET_GENERATOR_IMAGE) \ 135 | go run \ 136 | build/resources.go \ 137 | -packagedir $(shell dirname $(@)) 138 | 139 | .PHONY: container-test 140 | container-test: 141 | $(CONTAINER_ENGINE) run \ 142 | -v $(CURDIR):$(CURDIR):z \ 143 | -w $(CURDIR) \ 144 | -e GOFLAGS=$(GOFLAGS) \ 145 | --rm \ 146 | $(SYNCSET_GENERATOR_IMAGE) \ 147 | make test 148 | 149 | ### Imported 150 | .PHONY: container-image-push 151 | container-image-push: 152 | @if [[ -z $$QUAY_USER || -z $$QUAY_TOKEN ]]; then \ 153 | echo "You must set QUAY_USER and QUAY_TOKEN environment variables" ;\ 154 | echo "ex: make QUAY_USER=value QUAY_TOKEN=value $@" ;\ 155 | exit 1 ;\ 156 | fi 157 | $(CONTAINER_ENGINE) login -u="${QUAY_USER}" -p="${QUAY_TOKEN}" quay.io 158 | $(CONTAINER_ENGINE) push "${IMG}:${IMAGETAG}" 159 | $(CONTAINER_ENGINE) push "${IMG}:latest" 160 | 161 | .PHONY: package-push 162 | package-push: 163 | @if [[ -z $$QUAY_USER || -z $$QUAY_TOKEN ]]; then \ 164 | echo "You must set QUAY_USER and QUAY_TOKEN environment variables" ;\ 165 | echo "ex: make QUAY_USER=value QUAY_TOKEN=value $@" ;\ 166 | exit 1 ;\ 167 | fi 168 | $(CONTAINER_ENGINE) login -u="${QUAY_USER}" -p="${QUAY_TOKEN}" quay.io 169 | $(CONTAINER_ENGINE) push "${PKG_IMG}:${IMAGETAG}" 170 | $(CONTAINER_ENGINE) push "${PKG_IMG}:latest" 171 | 172 | .PHONY: push-base 173 | push-base: build/Dockerfile 174 | $(CONTAINER_ENGINE) push $(IMG):$(IMAGETAG) 175 | $(CONTAINER_ENGINE) push $(IMG):latest 176 | $(CONTAINER_ENGINE) push $(PKG_IMG):$(IMAGETAG) 177 | $(CONTAINER_ENGINE) push $(PKG_IMG):latest 178 | 179 | coverage: coverage.txt 180 | coverage.txt: vet $(GO_SOURCES) 181 | @./hack/test.sh 182 | 183 | .PHONY: docs 184 | docs: 185 | @# Ensure that the output from the test is hidden so this can be 186 | @# make docs > docs.json 187 | @# To hide the rules: make DOCFLAGS=-hideRules docs 188 | @$(MAKE test) 189 | @go run $(DOC_BINARY) $(DOCFLAGS) 190 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | reviewers: 2 | - joshbranham 3 | - robotmaxtron 4 | - srep-functional-team-rocket 5 | 6 | approvers: 7 | - joshbranham 8 | - robotmaxtron 9 | - tnierman 10 | - srep-functional-leads 11 | - srep-team-leads 12 | -------------------------------------------------------------------------------- /OWNERS_ALIASES: -------------------------------------------------------------------------------- 1 | # ================================ NOTICE ==================================== 2 | # This file is sourced from https://github.com/openshift/boilerplate 3 | # However, this repository is not currently subscribed to boilerplate, so manual updates are required 4 | # See the OWNERS_ALIASES docs: https://git.k8s.io/community/contributors/guide/owners.md#OWNERS_ALIASES 5 | # ============================================================================= 6 | aliases: 7 | srep-functional-team-aurora: 8 | - abyrne55 9 | - dakotalongRH 10 | - joshbranham 11 | - luis-falcon 12 | - reedcort 13 | srep-functional-team-fedramp: 14 | - tonytheleg 15 | - theautoroboto 16 | - rhdedgar 17 | - katherinelc321 18 | - rojasreinold 19 | - fsferraz-rh 20 | srep-functional-team-hulk: 21 | - a7vicky 22 | - ravitri 23 | - shitaljante 24 | - devppratik 25 | - Tafhim 26 | - tkong-redhat 27 | - TheUndeadKing 28 | - vaidehi411 29 | - chamalabey 30 | srep-functional-team-orange: 31 | - bergmannf 32 | - bng0y 33 | - typeid 34 | - Makdaam 35 | - mrWinston 36 | - Nikokolas3270 37 | - ninabauer 38 | - RaphaelBut 39 | srep-functional-team-rocket: 40 | - aliceh 41 | - anispate 42 | - clcollins 43 | - jimdaga 44 | - Mhodesty 45 | - nephomaniac 46 | - tnierman 47 | srep-functional-team-security: 48 | - jaybeeunix 49 | - sam-nguyen7 50 | - wshearn 51 | - dem4gus 52 | - npecka 53 | - pshickeydev 54 | - casey-williams-rh 55 | - boranx 56 | srep-functional-team-thor: 57 | - bmeng 58 | - MitaliBhalla 59 | - hectorakemp 60 | - feichashao 61 | - samanthajayasinghe 62 | - xiaoyu74 63 | - Dee-6777 64 | - Tessg22 65 | srep-infra-cicd: 66 | - mmazur 67 | - mrsantamaria 68 | - ritmun 69 | - jbpratt 70 | - yiqinzhang 71 | srep-functional-leads: 72 | - abyrne55 73 | - clcollins 74 | - Nikokolas3270 75 | - theautoroboto 76 | - bmeng 77 | - sam-nguyen7 78 | - ravitri 79 | srep-team-leads: 80 | - rafael-azevedo 81 | - iamkirkbater 82 | - rogbas 83 | - fahlmant 84 | - dustman9000 85 | - wanghaoran1988 86 | - bng0y 87 | sre-group-leads: 88 | - apahim 89 | - maorfr 90 | - rogbas 91 | srep-architects: 92 | - jewzaam 93 | - jharrington22 94 | - cblecker 95 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry.ci.openshift.org/openshift/release:golang-1.23 AS builder 2 | 3 | RUN mkdir -p /workdir 4 | WORKDIR /workdir 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | COPY . . 8 | RUN make build 9 | 10 | #### 11 | FROM registry.access.redhat.com/ubi9/ubi-minimal:9.5-1742914212 12 | 13 | ENV USER_UID=1001 \ 14 | USER_NAME=webhooks 15 | 16 | COPY --from=builder /workdir/build/_output/webhooks /usr/local/bin/ 17 | 18 | COPY build/bin /usr/local/bin 19 | RUN /usr/local/bin/user_setup 20 | 21 | ENTRYPOINT ["/usr/local/bin/entrypoint"] 22 | 23 | USER ${USER_UID} 24 | 25 | LABEL io.openshift.managed.name="managed-cluster-validating-webhooks" \ 26 | io.openshift.managed.description="Validating Webhooks for Openshift Dedicated" 27 | -------------------------------------------------------------------------------- /build/bin/entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | # This is documented here: 4 | # https://docs.openshift.com/container-platform/3.11/creating_images/guidelines.html#openshift-specific-guidelines 5 | 6 | if ! whoami &>/dev/null; then 7 | if [ -w /etc/passwd ]; then 8 | echo "${USER_NAME:-webhooks}:x:$(id -u):$(id -g):${USER_NAME:-webhooks} user:${HOME}:/sbin/nologin" >> /etc/passwd 9 | fi 10 | fi 11 | app="${1}" 12 | if [[ -z $app ]]; then 13 | echo "First parameter to entrypoint should be webhooks or injector" 14 | exit 1 15 | fi 16 | 17 | shift 18 | exec /usr/local/bin/$app $@ 19 | -------------------------------------------------------------------------------- /build/bin/user_setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -x 3 | 4 | # ensure $HOME exists and is accessible by group 0 (we don't know what the runtime UID will be) 5 | mkdir -p ${HOME} 6 | chown ${USER_UID}:0 ${HOME} 7 | chmod ug+rwx ${HOME} 8 | 9 | # runtime user will need to be able to self-insert in /etc/passwd 10 | chmod g+rw /etc/passwd 11 | 12 | # no need for this script to remain in the image after running 13 | rm $0 -------------------------------------------------------------------------------- /build/build_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # AppSRE team CD 4 | 5 | set -exv 6 | 7 | # TODO: Invoke this make target directly from appsre ci-int and scrap this file 8 | make -C $(dirname $0)/../ build-push 9 | make -C $(dirname $0)/../ build-push-package 10 | -------------------------------------------------------------------------------- /build/build_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | usage() { 4 | echo "Usage: $0 IMAGE_URI" >&2 5 | exit 1 6 | } 7 | 8 | ## image_exists_in_repo IMAGE_URI 9 | # 10 | # Checks whether IMAGE_URI -- e.g. quay.io/app-sre/osd-metrics-exporter:abcd123 11 | # -- exists in the remote repository. 12 | # If so, returns success. 13 | # If the image does not exist, but the query was otherwise successful, returns 14 | # failure. 15 | # If the query fails for any reason, prints an error and *exits* nonzero. 16 | # 17 | # This function cribbed from: 18 | # https://github.com/openshift/boilerplate/blob/0ba6566d544d0df9993a92b2286c131eb61f3e88/boilerplate/_lib/common.sh#L77-L135 19 | image_exists_in_repo() { 20 | local image_uri=$1 21 | local output 22 | local rc 23 | 24 | local skopeo_stderr=$(mktemp) 25 | 26 | output=$(skopeo inspect docker://${image_uri} 2>$skopeo_stderr) 27 | rc=$? 28 | # So we can delete the temp file right away... 29 | stderr=$(cat $skopeo_stderr) 30 | rm -f $skopeo_stderr 31 | if [[ $rc -eq 0 ]]; then 32 | # The image exists. Sanity check the output. 33 | local digest=$(echo $output | jq -r .Digest) 34 | if [[ -z "$digest" ]]; then 35 | echo "Unexpected error: skopeo inspect succeeded, but output contained no .Digest" 36 | echo "Here's the output:" 37 | echo "$output" 38 | echo "...and stderr:" 39 | echo "$stderr" 40 | exit 1 41 | fi 42 | echo "Image ${image_uri} exists with digest $digest." 43 | return 0 44 | elif [[ "$stderr" == *"manifest unknown"* ]]; then 45 | # We were able to talk to the repository, but the tag doesn't exist. 46 | # This is the normal "green field" case. 47 | echo "Image ${image_uri} does not exist in the repository." 48 | return 1 49 | elif [[ "$stderr" == *"was deleted or has expired"* ]]; then 50 | # This should be rare, but accounts for cases where we had to 51 | # manually delete an image. 52 | echo "Image ${image_uri} was deleted from the repository." 53 | echo "Proceeding as if it never existed." 54 | return 1 55 | else 56 | # Any other error. For example: 57 | # - "unauthorized: access to the requested resource is not 58 | # authorized". This happens not just on auth errors, but if we 59 | # reference a repository that doesn't exist. 60 | # - "no such host". 61 | # - Network or other infrastructure failures. 62 | # In all these cases, we want to bail, because we don't know whether 63 | # the image exists (and we'd likely fail to push it anyway). 64 | echo "Error querying the repository for ${image_uri}:" 65 | echo "stdout: $output" 66 | echo "stderr: $stderr" 67 | exit 1 68 | fi 69 | } 70 | 71 | set -exv 72 | 73 | IMAGE_URI=$1 74 | [[ -z "$IMAGE_URI" ]] && usage 75 | 76 | # NOTE(efried): Since we reference images by digest, rebuilding an image 77 | # with the same tag can be Bad. This is because the digest calculation 78 | # includes metadata such as date stamp, meaning that even though the 79 | # contents may be identical, the digest may change. In this situation, 80 | # the original digest URI no longer has any tags referring to it, so the 81 | # repository deletes it. This can break existing deployments referring 82 | # to the old digest. We could have solved this issue by generating a 83 | # permanent tag tied to each digest. We decided to do it this way 84 | # instead. 85 | # For testing purposes, if you need to force the build/push to rerun, 86 | # delete the image at $IMAGE_URI. 87 | if image_exists_in_repo "$IMAGE_URI"; then 88 | echo "Image ${IMAGE_URI} already exists. Nothing to do!" 89 | exit 0 90 | fi 91 | 92 | # build the image, the selectorsyncset, and push the image 93 | make -C $(dirname $0)/../ syncset build-base container-image-push 94 | -------------------------------------------------------------------------------- /build/build_push_package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | usage() { 4 | echo "Usage: $0 IMAGE_URI" >&2 5 | exit 1 6 | } 7 | 8 | ## image_exists_in_repo IMAGE_URI 9 | # 10 | # Checks whether IMAGE_URI -- e.g. quay.io/app-sre/osd-metrics-exporter:abcd123 11 | # -- exists in the remote repository. 12 | # If so, returns success. 13 | # If the image does not exist, but the query was otherwise successful, returns 14 | # failure. 15 | # If the query fails for any reason, prints an error and *exits* nonzero. 16 | # 17 | # This function cribbed from: 18 | # https://github.com/openshift/boilerplate/blob/0ba6566d544d0df9993a92b2286c131eb61f3e88/boilerplate/_lib/common.sh#L77-L135 19 | image_exists_in_repo() { 20 | local image_uri=$1 21 | local output 22 | local rc 23 | 24 | local skopeo_stderr=$(mktemp) 25 | 26 | output=$(skopeo inspect docker://${image_uri} 2>$skopeo_stderr) 27 | rc=$? 28 | # So we can delete the temp file right away... 29 | stderr=$(cat $skopeo_stderr) 30 | rm -f $skopeo_stderr 31 | if [[ $rc -eq 0 ]]; then 32 | # The image exists. Sanity check the output. 33 | local digest=$(echo $output | jq -r .Digest) 34 | if [[ -z "$digest" ]]; then 35 | echo "Unexpected error: skopeo inspect succeeded, but output contained no .Digest" 36 | echo "Here's the output:" 37 | echo "$output" 38 | echo "...and stderr:" 39 | echo "$stderr" 40 | exit 1 41 | fi 42 | echo "Image ${image_uri} exists with digest $digest." 43 | return 0 44 | elif [[ "$stderr" == *"manifest unknown"* ]]; then 45 | # We were able to talk to the repository, but the tag doesn't exist. 46 | # This is the normal "green field" case. 47 | echo "Image ${image_uri} does not exist in the repository." 48 | return 1 49 | elif [[ "$stderr" == *"was deleted or has expired"* ]]; then 50 | # This should be rare, but accounts for cases where we had to 51 | # manually delete an image. 52 | echo "Image ${image_uri} was deleted from the repository." 53 | echo "Proceeding as if it never existed." 54 | return 1 55 | else 56 | # Any other error. For example: 57 | # - "unauthorized: access to the requested resource is not 58 | # authorized". This happens not just on auth errors, but if we 59 | # reference a repository that doesn't exist. 60 | # - "no such host". 61 | # - Network or other infrastructure failures. 62 | # In all these cases, we want to bail, because we don't know whether 63 | # the image exists (and we'd likely fail to push it anyway). 64 | echo "Error querying the repository for ${image_uri}:" 65 | echo "stdout: $output" 66 | echo "stderr: $stderr" 67 | exit 1 68 | fi 69 | } 70 | 71 | set -exv 72 | 73 | IMAGE_URI=$1 74 | [[ -z "$IMAGE_URI" ]] && usage 75 | 76 | # NOTE(efried): Since we reference images by digest, rebuilding an image 77 | # with the same tag can be Bad. This is because the digest calculation 78 | # includes metadata such as date stamp, meaning that even though the 79 | # contents may be identical, the digest may change. In this situation, 80 | # the original digest URI no longer has any tags referring to it, so the 81 | # repository deletes it. This can break existing deployments referring 82 | # to the old digest. We could have solved this issue by generating a 83 | # permanent tag tied to each digest. We decided to do it this way 84 | # instead. 85 | # For testing purposes, if you need to force the build/push to rerun, 86 | # delete the image at $IMAGE_URI. 87 | if image_exists_in_repo "$IMAGE_URI"; then 88 | echo "Image ${IMAGE_URI} already exists. Nothing to do!" 89 | exit 0 90 | fi 91 | 92 | # build the image, the selectorsyncset, and push the image 93 | make -C $(dirname $0)/../ package build-base package-push 94 | -------------------------------------------------------------------------------- /build/pr_check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo "Using git version $(git version)" 6 | echo "Using go version $(go version)" 7 | 8 | CURRENT_DIR=$(dirname "$0") 9 | 10 | #BUILD_CMD="build-base" make lint test build-sss build-base 11 | make -C $(dirname $0)/../ container-test syncset package build-base 12 | 13 | # make sure nothing changed (i.e. SSS templates being invalid) 14 | git diff --exit-code 15 | MAKE_RC=$? 16 | 17 | if [ "$MAKE_RC" != "0" ]; 18 | then 19 | echo "FAILURE: unexpected changes after building." 20 | exit $MAKE_RC 21 | fi 22 | -------------------------------------------------------------------------------- /cmd/fips.go: -------------------------------------------------------------------------------- 1 | //go:build fips_enabled 2 | // +build fips_enabled 3 | 4 | // BOILERPLATE GENERATED -- DO NOT EDIT 5 | // Run 'make ensure-fips' to regenerate 6 | 7 | package main 8 | 9 | import ( 10 | _ "crypto/tls/fipsonly" 11 | "fmt" 12 | ) 13 | 14 | func init() { 15 | fmt.Println("***** Starting with FIPS crypto enabled *****") 16 | } 17 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "net" 11 | "net/http" 12 | "os" 13 | 14 | "github.com/openshift/operator-custom-metrics/pkg/metrics" 15 | klog "k8s.io/klog/v2" 16 | "k8s.io/klog/v2/klogr" 17 | logf "sigs.k8s.io/controller-runtime/pkg/log" 18 | 19 | "github.com/thoseaunt/managed-cluster-validating-webhooks/config" 20 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/dispatcher" 21 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/k8sutil" 22 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/localmetrics" 23 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks" 24 | ) 25 | 26 | var log = logf.Log.WithName("handler") 27 | 28 | var ( 29 | listenAddress = flag.String("listen", "0.0.0.0", "listen address") 30 | listenPort = flag.String("port", "5000", "port to listen on") 31 | testHooks = flag.Bool("testhooks", false, "Test webhook URI uniqueness and quit?") 32 | 33 | useTLS = flag.Bool("tls", false, "Use TLS? Must specify -tlskey, -tlscert, -cacert") 34 | tlsKey = flag.String("tlskey", "", "TLS Key for TLS") 35 | tlsCert = flag.String("tlscert", "", "TLS Certificate") 36 | caCert = flag.String("cacert", "", "CA Cert file") 37 | 38 | metricsPath = "/metrics" 39 | metricsPort = "8080" 40 | ) 41 | 42 | func main() { 43 | var metricsAddr string 44 | flag.StringVar(&metricsAddr, "metrics-bind-address", ":"+metricsPort, "The address the metric endpoint binds to.") 45 | flag.Parse() 46 | klog.SetOutput(os.Stdout) 47 | 48 | logf.SetLogger(klogr.New()) 49 | 50 | if !*testHooks { 51 | log.Info("HTTP server running at", "listen", net.JoinHostPort(*listenAddress, *listenPort)) 52 | } 53 | dispatcher := dispatcher.NewDispatcher(webhooks.Webhooks) 54 | seen := make(map[string]bool) 55 | for name, hook := range webhooks.Webhooks { 56 | realHook := hook() 57 | if seen[realHook.GetURI()] { 58 | panic(fmt.Errorf("Duplicate webhook trying to listen on %s", realHook.GetURI())) 59 | } 60 | seen[name] = true 61 | if !*testHooks { 62 | log.Info("Listening", "webhookName", name, "URI", realHook.GetURI()) 63 | } 64 | http.HandleFunc(realHook.GetURI(), dispatcher.HandleRequest) 65 | } 66 | if *testHooks { 67 | os.Exit(0) 68 | } 69 | 70 | // start metrics server 71 | metricsServer := metrics.NewBuilder(config.OperatorNamespace, fmt.Sprintf("%s-metrics", config.OperatorName)). 72 | WithPort(metricsPort). 73 | WithPath(metricsPath). 74 | WithServiceLabel(map[string]string{"app": "validation-webhook"}). 75 | WithCollectors(localmetrics.MetricsList). 76 | GetConfig() 77 | 78 | // get the namespace we're running in to confirm if running in a cluster 79 | if _, err := k8sutil.GetOperatorNamespace(); err != nil { 80 | if errors.Is(err, k8sutil.ErrRunLocal) { 81 | log.Info("Skipping metrics server creation; not running in a cluster.") 82 | } else { 83 | log.Error(err, "Failed to get operator namespace") 84 | } 85 | } else { 86 | if err := metrics.ConfigureMetrics(context.TODO(), *metricsServer); err != nil { 87 | log.Error(err, "Failed to configure metrics") 88 | } else { 89 | log.Info("Successfully configured metrics") 90 | } 91 | } 92 | 93 | server := &http.Server{ 94 | Addr: net.JoinHostPort(*listenAddress, *listenPort), 95 | } 96 | if *useTLS { 97 | cafile, err := os.ReadFile(*caCert) 98 | if err != nil { 99 | log.Error(err, "Couldn't read CA cert file") 100 | os.Exit(1) 101 | } 102 | certpool := x509.NewCertPool() 103 | certpool.AppendCertsFromPEM(cafile) 104 | 105 | server.TLSConfig = &tls.Config{ 106 | RootCAs: certpool, 107 | } 108 | log.Error(server.ListenAndServeTLS(*tlsCert, *tlsKey), "Error serving TLS") 109 | } else { 110 | log.Error(server.ListenAndServe(), "Error serving non-TLS connection") 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const ( 4 | // I know this isn't the operator's name but so much stuff has been coded to use this... 5 | OperatorName = "validation-webhook" 6 | OperatorNamespace = "openshift-validation-webhook" 7 | ) 8 | -------------------------------------------------------------------------------- /config/package/managed-cluster-validating-webhooks-package.Containerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | ADD config/package/*.yaml* /package/ 4 | -------------------------------------------------------------------------------- /config/package/manifest.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: manifests.package-operator.run/v1alpha1 2 | kind: PackageManifest 3 | metadata: 4 | name: validation-webhook 5 | spec: 6 | scopes: 7 | - Namespaced 8 | phases: 9 | - name: config 10 | - name: rbac 11 | - name: deploy 12 | - name: webhooks 13 | class: hosted-cluster 14 | config: 15 | openAPIV3Schema: 16 | properties: 17 | serviceca: 18 | description: Service Certificate Authority used for webhook client authentication 19 | type: string 20 | required: 21 | - serviceca 22 | type: object 23 | availabilityProbes: 24 | - probes: 25 | - condition: 26 | type: Available 27 | status: "True" 28 | - fieldsEqual: 29 | fieldA: .status.updatedReplicas 30 | fieldB: .status.replicas 31 | selector: 32 | kind: 33 | group: apps 34 | kind: Deployment 35 | -------------------------------------------------------------------------------- /docs/hypershift.md: -------------------------------------------------------------------------------- 1 | # managed-cluster-validating-webhooks on Hypershift 2 | 3 | ## How it works 4 | 5 | Managed Cluster Validating Webhooks (MCVW) is deployed into Hypershift environments via several different components. 6 | 7 | - The webhook admission service is deployed into each hosted control plane (HCP) namespace on Hypershift management clusters, via [package-operator](https://package-operator.run/) 8 | - The `ValidatingWebhookConfiguration` resources are deployed directly onto Hypershift hosted clusters. 9 | 10 | The above components are both installed via a [package operator](https://package-operator.run/) (PKO) package. The package is distributed to Hypershift Management Clusters via an Advanced Cluster Management policy. These resources will be discussed in the section below. 11 | 12 | ## Package Operator package 13 | 14 | The PKO package consists of: 15 | - [a manifest](../config/package/manifest.yaml) which lists the phases involved in the package installation, any availability and promotion tests. 16 | - [a resource bundle](../config/package/resources.yaml.gotmpl) which contains all the resources needed for MCVW to run in the HCP namespace, as well as the ValidatingWebhookConfigurations installed on the hosted cluster. This bundle is dynamically generated by [resources.go](../build/resources.go). Each resource is annotated with a phase so that PKO knows during which phase the resource should be installed. 17 | - [a Containerfile](../config/package/managed-cluster-validating-webhooks-package.Containerfile) which builds the PKO package image. 18 | 19 | ### Building a package 20 | 21 | You can manually rebuild or generate the resource bundle by running: 22 | 23 | ```bash 24 | make package 25 | ``` 26 | 27 | You can manually build the PKO package image by running: 28 | ```bash 29 | make IMG_ORG= build-package-image 30 | ``` 31 | 32 | Note that the resulting package image will follow the naming convention `quay.io/$USER/managed-cluster-validating-webhooks-hs-package` 33 | and can be pushed to Quay for testing if needed. 34 | 35 | ### Testing a package 36 | 37 | Once a package has been built (and pushed to a public image repository) it can be manually installed on a PKO-running cluster by creating a simple `Package` spec: 38 | 39 | ```yaml 40 | apiVersion: package-operator.run/v1alpha1 41 | kind: Package 42 | metadata: 43 | name: validation-webhook 44 | namespace: validation-webhook 45 | spec: 46 | image: quay.io/$USER/managed-cluster-validating-webhooks-hs-package:$TAG 47 | ``` 48 | 49 | ## ACM Policy for Package distribution 50 | 51 | On Hypershift, the `Package` resource is distributed to all HCP Namespaces via a [SelectorSyncSet](../hack/templates/00-managed-cluster-validating-webhooks-hs.SelectorSyncSet.yaml.tmpl) containing ACM Policy. 52 | 53 | The application of the SelectorSyncSet to Hive clusters (in turn distributing it to the Hypershift service clusters) is performed by [app-interface](https://gitlab.cee.redhat.com/service/app-interface/-/blob/master/data/services/osd-operators/cicd/saas/saas-managed-cluster-validating-webhooks.yaml). 54 | 55 | ## How the CI/CD process works 56 | 57 | This section describes the main steps that enable a CI/CD flow for `managed-cluster-validating-webhooks`: 58 | 59 | - A new commit is merged to the MCVW repository. 60 | - This [triggers app-interface](https://gitlab.cee.redhat.com/service/app-interface/-/blob/master/data/services/osd-operators/cicd/ci-int/jobs-managed-cluster-validating-webhooks.yaml) to call the MCVW [build_deploy.sh](https://github.com/thoseaunt/managed-cluster-validating-webhooks/blob/master/build/build_deploy.sh) script. 61 | - The `build_deploy.sh` script builds a new MCVW image and a new PKO package. Each are tagged with the same git short hash representing the commit that was just merged. 62 | - The `managed-cluster-validating-webhooks-hypershift` SaaS [resource template in app-interface](https://gitlab.cee.redhat.com/service/app-interface/-/blob/master/data/services/osd-operators/cicd/saas/saas-managed-cluster-validating-webhooks.yaml) will roll out the latest templated [SelectorSyncSet](https://github.com/thoseaunt/managed-cluster-validating-webhooks/blob/master/hack/templates/00-managed-cluster-validating-webhooks-hs.SelectorSyncSet.yaml.tmpl) to staging/integration Hive shards. The `IMAGE_DIGEST` value will be replaced by the git short hash of the latest commit; therefore, the PKO image referenced will be the one built by the earlier step. 63 | - Because the ACM Policy has changed, the Policy will be updated on all Hypershift Management Clusters. This will result in the `Package` resource updating in every HCP Namespace to reference the new PKO image. 64 | - PKO will download that PKO image and install or update the resources contained within. -------------------------------------------------------------------------------- /docs/webhooks-short.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "webhookName": "clusterlogging-validation", 4 | "documentString": "Managed OpenShift Customers may set log retention outside the allowed range of 0-7 days" 5 | }, 6 | { 7 | "webhookName": "clusterrolebindings-validation", 8 | "documentString": "Managed OpenShift Customers may not delete the cluster role bindings under the managed namespaces: (^openshift-.*|kube-system)" 9 | }, 10 | { 11 | "webhookName": "customresourcedefinitions-validation", 12 | "documentString": "Managed OpenShift Customers may not change CustomResourceDefinitions managed by Red Hat." 13 | }, 14 | { 15 | "webhookName": "hiveownership-validation", 16 | "documentString": "Managed OpenShift customers may not edit certain managed resources. A managed resource has a \"hive.openshift.io/managed\": \"true\" label." 17 | }, 18 | { 19 | "webhookName": "imagecontentpolicies-validation", 20 | "documentString": "Managed OpenShift customers may not create ImageContentSourcePolicy, ImageDigestMirrorSet, or ImageTagMirrorSet resources that configure mirrors that would conflict with system registries (e.g. quay.io, registry.redhat.io, registry.access.redhat.com, etc). For more details, see https://docs.openshift.com/" 21 | }, 22 | { 23 | "webhookName": "ingress-config-validation", 24 | "documentString": "Managed OpenShift customers may not modify ingress config resources because it can can degrade cluster operators and can interfere with OpenShift SRE monitoring." 25 | }, 26 | { 27 | "webhookName": "ingresscontroller-validation", 28 | "documentString": "Managed OpenShift Customer may create IngressControllers without necessary taints. This can cause those workloads to be provisioned on infra or master nodes." 29 | }, 30 | { 31 | "webhookName": "namespace-validation", 32 | "documentString": "Managed OpenShift Customers may not modify namespaces specified in the [openshift-monitoring/managed-namespaces openshift-monitoring/ocp-namespaces] ConfigMaps because customer workloads should be placed in customer-created namespaces. Customers may not create namespaces identified by this regular expression (^com$|^io$|^in$) because it could interfere with critical DNS resolution. Additionally, customers may not set or change the values of these Namespace labels [managed.openshift.io/storage-pv-quota-exempt managed.openshift.io/service-lb-quota-exempt]." 33 | }, 34 | { 35 | "webhookName": "networkpolicies-validation", 36 | "documentString": "Managed OpenShift Customers may not create NetworkPolicies in namespaces managed by Red Hat." 37 | }, 38 | { 39 | "webhookName": "node-validation-osd", 40 | "documentString": "Managed OpenShift customers may not alter Node objects." 41 | }, 42 | { 43 | "webhookName": "pod-validation", 44 | "documentString": "Managed OpenShift Customers may use tolerations on Pods that could cause those Pods to be scheduled on infra or master nodes." 45 | }, 46 | { 47 | "webhookName": "prometheusrule-validation", 48 | "documentString": "Managed OpenShift Customers may not create PrometheusRule in namespaces managed by Red Hat." 49 | }, 50 | { 51 | "webhookName": "regular-user-validation", 52 | "documentString": "Managed OpenShift customers may not manage any objects in the following APIGroups [admissionregistration.k8s.io managed.openshift.io addons.managed.openshift.io ocmagent.managed.openshift.io upgrade.managed.openshift.io config.openshift.io operator.openshift.io network.openshift.io cloudcredential.openshift.io machine.openshift.io splunkforwarder.managed.openshift.io autoscaling.openshift.io cloudingress.managed.openshift.io machineconfiguration.openshift.io], nor may Managed OpenShift customers alter the APIServer, KubeAPIServer, OpenShiftAPIServer, ClusterVersion, Proxy or SubjectPermission objects." 53 | }, 54 | { 55 | "webhookName": "scc-validation", 56 | "documentString": "Managed OpenShift Customers may not modify the following default SCCs: [anyuid hostaccess hostmount-anyuid hostnetwork hostnetwork-v2 node-exporter nonroot nonroot-v2 privileged restricted restricted-v2]" 57 | }, 58 | { 59 | "webhookName": "sdn-migration-validation", 60 | "documentString": "Managed OpenShift customers may not modify the network config type because it can can degrade cluster operators and can interfere with OpenShift SRE monitoring." 61 | }, 62 | { 63 | "webhookName": "service-mutation", 64 | "documentString": "LoadBalancer-type services on Managed OpenShift clusters must contain an additional annotation for managed policy compliance." 65 | }, 66 | { 67 | "webhookName": "serviceaccount-validation", 68 | "documentString": "Managed OpenShift Customers may not delete the service accounts under the managed namespaces。" 69 | }, 70 | { 71 | "webhookName": "techpreviewnoupgrade-validation", 72 | "documentString": "Managed OpenShift Customers may not use TechPreviewNoUpgrade FeatureGate that could prevent any future ability to do a y-stream upgrade to their clusters." 73 | } 74 | ] 75 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/thoseaunt/managed-cluster-validating-webhooks 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/evanphx/json-patch v4.12.0+incompatible 9 | github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 10 | github.com/go-logr/logr v1.4.2 11 | github.com/onsi/ginkgo/v2 v2.19.1 12 | github.com/onsi/gomega v1.34.1 13 | github.com/openshift/api v0.0.0-20240522145529-93d6bda14341 14 | github.com/openshift/cluster-logging-operator v0.0.0-20230328172346-05f4f8be54d5 15 | github.com/openshift/hive/apis v0.0.0-20230327212335-7fd70848a6d5 16 | github.com/openshift/operator-custom-metrics v0.5.1 17 | github.com/openshift/osde2e-common v0.0.0-20231010150014-8a4449a371e6 18 | github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.55.1 19 | github.com/prometheus/client_golang v1.20.4 20 | gomodules.xyz/jsonpatch/v2 v2.3.0 21 | k8s.io/api v0.29.0 22 | k8s.io/apiextensions-apiserver v0.27.2 23 | k8s.io/apimachinery v0.29.0 24 | k8s.io/client-go v0.29.0 25 | k8s.io/klog/v2 v2.120.1 26 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b 27 | sigs.k8s.io/controller-runtime v0.15.1 28 | sigs.k8s.io/e2e-framework v0.3.0 29 | ) 30 | 31 | require ( 32 | github.com/beorn7/perks v1.0.1 // indirect 33 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 34 | github.com/davecgh/go-spew v1.1.1 // indirect 35 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 36 | github.com/evanphx/json-patch/v5 v5.6.0 // indirect 37 | github.com/fsnotify/fsnotify v1.7.0 // indirect 38 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 39 | github.com/go-openapi/jsonreference v0.20.2 // indirect 40 | github.com/go-openapi/swag v0.22.3 // indirect 41 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 42 | github.com/gogo/protobuf v1.3.2 // indirect 43 | github.com/golang/protobuf v1.5.3 // indirect 44 | github.com/google/gnostic-models v0.6.8 // indirect 45 | github.com/google/go-cmp v0.6.0 // indirect 46 | github.com/google/gofuzz v1.2.0 // indirect 47 | github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 // indirect 48 | github.com/google/uuid v1.3.0 // indirect 49 | github.com/gorilla/websocket v1.5.0 // indirect 50 | github.com/imdario/mergo v0.3.15 // indirect 51 | github.com/josharian/intern v1.0.0 // indirect 52 | github.com/json-iterator/go v1.1.12 // indirect 53 | github.com/klauspost/compress v1.17.9 // indirect 54 | github.com/mailru/easyjson v0.7.7 // indirect 55 | github.com/moby/spdystream v0.2.0 // indirect 56 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 57 | github.com/modern-go/reflect2 v1.0.2 // indirect 58 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 59 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 60 | github.com/openshift/custom-resource-status v1.1.3-0.20220503160415-f2fdb4999d87 // indirect 61 | github.com/openshift/elasticsearch-operator v0.0.0-20220613183908-e1648e67c298 // indirect 62 | github.com/pkg/errors v0.9.1 // indirect 63 | github.com/prometheus/client_model v0.6.1 // indirect 64 | github.com/prometheus/common v0.59.1 // indirect 65 | github.com/prometheus/procfs v0.15.1 // indirect 66 | github.com/spf13/pflag v1.0.5 // indirect 67 | go.uber.org/atomic v1.10.0 // indirect 68 | go.uber.org/multierr v1.11.0 // indirect 69 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 70 | golang.org/x/net v0.36.0 // indirect 71 | golang.org/x/oauth2 v0.22.0 // indirect 72 | golang.org/x/sys v0.30.0 // indirect 73 | golang.org/x/term v0.29.0 // indirect 74 | golang.org/x/text v0.22.0 // indirect 75 | golang.org/x/time v0.3.0 // indirect 76 | golang.org/x/tools v0.23.0 // indirect 77 | google.golang.org/protobuf v1.34.2 // indirect 78 | gopkg.in/inf.v0 v0.9.1 // indirect 79 | gopkg.in/yaml.v2 v2.4.0 // indirect 80 | gopkg.in/yaml.v3 v3.0.1 // indirect 81 | k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect 82 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 83 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 84 | sigs.k8s.io/yaml v1.3.0 // indirect 85 | ) 86 | -------------------------------------------------------------------------------- /hack/documentation/document.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Offer a way to auto-generate documentation 4 | 5 | import ( 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "sort" 11 | 12 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks" 13 | admissionregv1 "k8s.io/api/admissionregistration/v1" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | ) 16 | 17 | var ( 18 | hideRules = flag.Bool("hideRules", false, "Hide the Admission Rules?") 19 | ) 20 | 21 | type docuhook struct { 22 | Name string `json:"webhookName"` 23 | Rules []admissionregv1.RuleWithOperations `json:"rules,omitempty"` 24 | ObjectSelector *metav1.LabelSelector `json:"webhookObjectSelector,omitempty"` 25 | DocumentationString string `json:"documentString"` 26 | } 27 | 28 | // WriteDocs will write out all the docs. 29 | func WriteDocs() { 30 | hookNames := make([]string, 0) 31 | for name := range webhooks.Webhooks { 32 | hookNames = append(hookNames, name) 33 | } 34 | sort.Strings(hookNames) 35 | dochooks := make([]docuhook, len(hookNames)) 36 | 37 | for i, hookName := range hookNames { 38 | hook := webhooks.Webhooks[hookName] 39 | realHook := hook() 40 | dochooks[i].Name = realHook.Name() 41 | dochooks[i].DocumentationString = realHook.Doc() 42 | if !*hideRules { 43 | dochooks[i].Rules = realHook.Rules() 44 | dochooks[i].ObjectSelector = realHook.ObjectSelector() 45 | } 46 | } 47 | 48 | b, err := json.MarshalIndent(&dochooks, "", " ") 49 | if err != nil { 50 | fmt.Printf("Error encoding: %s\n", err.Error()) 51 | os.Exit(1) 52 | } 53 | _, err = os.Stdout.Write(b) 54 | if err != nil { 55 | fmt.Printf("Error Writing: %s\n", err.Error()) 56 | os.Exit(1) 57 | } 58 | 59 | fmt.Println() 60 | 61 | } 62 | 63 | func main() { 64 | flag.Parse() 65 | WriteDocs() 66 | } 67 | -------------------------------------------------------------------------------- /hack/templates/00-managed-cluster-validating-webhooks-hs.SelectorSyncSet.yaml.tmpl: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Template 3 | metadata: 4 | name: hs-managed-cluster-validating-webhooks-template 5 | parameters: 6 | - name: REGISTRY_IMG 7 | required: true 8 | - name: IMAGE_DIGEST 9 | required: true 10 | objects: 11 | - apiVersion: hive.openshift.io/v1 12 | kind: SelectorSyncSet 13 | metadata: 14 | name: managed-cluster-validating-webhooks-hs-policy 15 | spec: 16 | clusterDeploymentSelector: 17 | matchLabels: 18 | ext-hypershift.openshift.io/cluster-type: service-cluster 19 | resourceApplyMode: Sync 20 | resources: 21 | - apiVersion: apps.open-cluster-management.io/v1 22 | kind: PlacementRule 23 | metadata: 24 | name: managed-cluster-validating-webhooks 25 | namespace: openshift-acm-policies 26 | spec: 27 | clusterSelector: 28 | matchExpressions: 29 | - key: hypershift.open-cluster-management.io/management-cluster 30 | operator: In 31 | values: 32 | - "true" 33 | - apiVersion: policy.open-cluster-management.io/v1 34 | kind: PlacementBinding 35 | metadata: 36 | name: managed-cluster-validating-webhooks 37 | namespace: openshift-acm-policies 38 | placementRef: 39 | name: managed-cluster-validating-webhooks 40 | kind: PlacementRule 41 | apiGroup: apps.open-cluster-management.io 42 | subjects: 43 | - name: managed-cluster-validating-webhooks 44 | kind: Policy 45 | apiGroup: policy.open-cluster-management.io 46 | - apiVersion: policy.open-cluster-management.io/v1 47 | kind: Policy 48 | metadata: 49 | name: managed-cluster-validating-webhooks 50 | namespace: openshift-acm-policies 51 | spec: 52 | remediationAction: enforce 53 | disabled: false 54 | policy-templates: 55 | - objectDefinition: 56 | apiVersion: policy.open-cluster-management.io/v1 57 | kind: ConfigurationPolicy 58 | metadata: 59 | name: managed-cluster-validating-webhooks 60 | annotations: 61 | policy.open-cluster-management.io/disable-templates: "true" 62 | spec: 63 | namespaceSelector: 64 | matchLabels: 65 | hypershift.openshift.io/hosted-control-plane: "true" 66 | pruneObjectBehavior: DeleteIfCreated 67 | object-templates: 68 | - complianceType: MustHave 69 | objectDefinition: 70 | apiVersion: package-operator.run/v1alpha1 71 | kind: ObjectTemplate 72 | metadata: 73 | name: validation-webhooks 74 | spec: 75 | template: | 76 | apiVersion: package-operator.run/v1alpha1 77 | kind: Package 78 | metadata: 79 | name: validation-webhooks 80 | spec: 81 | image: ${REGISTRY_IMG}@${IMAGE_DIGEST} 82 | config: {{toJson .config}} 83 | sources: 84 | - apiVersion: v1 85 | kind: ConfigMap 86 | name: openshift-service-ca.crt 87 | items: 88 | - key: .data['service-ca\.crt'] 89 | destination: .serviceca 90 | -------------------------------------------------------------------------------- /hack/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd $(dirname $0)/../ 6 | 7 | echo "" > coverage.txt 8 | 9 | for d in $(go list ./... | grep -v vendor); do 10 | go test -race -coverprofile=profile.out -covermode=atomic $d 11 | if [ -f profile.out ]; then 12 | cat profile.out >> coverage.txt 13 | rm profile.out 14 | fi 15 | done -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | //go:generate go run ./generate/namespaces.go 4 | import ( 5 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 6 | ) 7 | 8 | func IsPrivilegedNamespace(ns string) bool { 9 | return utils.RegexSliceContains(ns, PrivilegedNamespaces) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/config/generate/namespaces.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "bufio" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "os" 12 | "text/template" 13 | "time" 14 | 15 | "github.com/ghodss/yaml" 16 | corev1 "k8s.io/api/core/v1" 17 | ) 18 | 19 | var namespaceFiles = []string{ 20 | "managed-namespaces.ConfigMap.yaml", 21 | "ocp-namespaces.ConfigMap.yaml", 22 | } 23 | 24 | var ( 25 | // Base lists - default values which will always be enforced regardless of managed-cluster-config 26 | namespaces = []string{"^default$", "^openshift$", "^kube-.*", "^redhat-.*"} 27 | configmaps = []string{} 28 | ) 29 | 30 | const ( 31 | // generatedFileName defines the path to the generated file relative to the invoking go:generate command 32 | generatedFileName = "./namespaces.go" 33 | 34 | mccBaseUrl = "https://raw.githubusercontent.com/openshift/managed-cluster-config/master/deploy/osd-managed-resources" 35 | serviceAccountHeader = `^system:serviceaccounts:` 36 | namespacesKey = "managed_namespaces.yaml" 37 | ) 38 | 39 | const templateText = `// Code generated by pkg/config/generate/namespaces.go; DO NOT EDIT. 40 | // Generated at {{ .Timestamp }} 41 | package config 42 | 43 | var ConfigMapSources = []string{ 44 | {{- range .ConfigMaps }} 45 | "{{ printf "%s" . }}", 46 | {{- end }} 47 | } 48 | 49 | var PrivilegedNamespaces = []string{ 50 | {{- range .Namespaces }} 51 | "{{ printf "%s" . }}", 52 | {{- end }} 53 | } 54 | ` 55 | 56 | type templateArgs struct { 57 | Timestamp time.Time 58 | ConfigMaps []string 59 | Namespaces []string 60 | ServiceAccounts []string 61 | } 62 | 63 | // ManagedNamespacesConfig defines the structure of the managed_namespaces.yaml file from the managed-namespaces ConfigMap 64 | type NamespacesConfig struct { 65 | Resources NamespaceList `yaml:"Resources,omitempty" json:"Resources,omitempty"` 66 | } 67 | 68 | type NamespaceList struct { 69 | Namespace []Namespace `yaml:"Namespace,omitempty" json:"Namespace,omitempty"` 70 | } 71 | 72 | type Namespace struct { 73 | Name string `yaml:"name,omitempty" json:"name,omitempty"` 74 | } 75 | 76 | func main() { 77 | // Retrieve current configuration from managed-cluster-config 78 | for _, fileName := range namespaceFiles { 79 | // GET files and read contents 80 | fileUrl := fmt.Sprintf("%s/%s", mccBaseUrl, fileName) 81 | response, err := http.Get(fileUrl) 82 | if err != nil { 83 | log.Fatalf("Error retrieving file from managed-cluster-config: %v", err) 84 | return 85 | } 86 | defer func() { 87 | err := response.Body.Close() 88 | if err != nil { 89 | log.Fatalf("Error closing response body: %v", err) 90 | } 91 | }() 92 | 93 | scanner := bufio.NewScanner(response.Body) 94 | rawFile := []byte{} 95 | for scanner.Scan() { 96 | rawFile = append(rawFile, scanner.Bytes()...) 97 | // Newlines must be manually appended, Scan() only reads in the line contents 98 | rawFile = append(rawFile, []byte("\n")...) 99 | } 100 | if scanner.Err() != nil { 101 | log.Fatalf("Error reading response body: %v", scanner.Err()) 102 | } 103 | 104 | // Convert file contents to ConfigMap; convert ConfigMap data to NamespaceConfig format 105 | nsConfigMap := corev1.ConfigMap{} 106 | err = yaml.Unmarshal(rawFile, &nsConfigMap) 107 | if err != nil { 108 | log.Fatalf("Error decoding response: %v", err) 109 | } 110 | 111 | rawConfig := []byte(nsConfigMap.Data[namespacesKey]) 112 | nsConfig := NamespacesConfig{} 113 | err = yaml.Unmarshal(rawConfig, &nsConfig) 114 | if err != nil { 115 | log.Fatalf("Error decoding configMap: %v", err) 116 | } else if len(nsConfig.Resources.Namespace) == 0 { 117 | log.Fatalf("No namespaces retrieved from %s", fileName) 118 | } 119 | 120 | // Save retrieved namespaces, serviceaccounts, and configmap info 121 | configmaps = append(configmaps, fmt.Sprintf("%s/%s", nsConfigMap.Namespace, nsConfigMap.Name)) 122 | for _, ns := range nsConfig.Resources.Namespace { 123 | namespaces = append(namespaces, "^"+ns.Name+"$") 124 | } 125 | } 126 | 127 | // Write data to file 128 | genFile, err := os.Create(generatedFileName) 129 | if err != nil { 130 | log.Fatalf("Error creating file %s: %v", generatedFileName, err) 131 | } 132 | defer func() { 133 | err = genFile.Close() 134 | if err != nil { 135 | log.Fatalf("Error closing file %s: %v", genFile.Name(), err) 136 | } 137 | }() 138 | 139 | namespaceTemplateArgs := templateArgs{ 140 | Timestamp: time.Now().UTC(), 141 | ConfigMaps: configmaps, 142 | Namespaces: namespaces, 143 | } 144 | namespaceTemplate, err := template.New(generatedFileName).Parse(templateText) 145 | if err != nil { 146 | log.Fatalf("Error initializing template: %v", err) 147 | } 148 | 149 | err = namespaceTemplate.Execute(genFile, namespaceTemplateArgs) 150 | if err != nil { 151 | log.Fatalf("Error generating file from template: %v", err) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /pkg/config/namespaces.go: -------------------------------------------------------------------------------- 1 | // Code generated by pkg/config/generate/namespaces.go; DO NOT EDIT. 2 | // Generated at 2025-05-13 14:45:56.423717 +0000 UTC 3 | package config 4 | 5 | var ConfigMapSources = []string{ 6 | "openshift-monitoring/managed-namespaces", 7 | "openshift-monitoring/ocp-namespaces", 8 | } 9 | 10 | var PrivilegedNamespaces = []string{ 11 | "^default$", 12 | "^openshift$", 13 | "^kube-.*", 14 | "^redhat-.*", 15 | "^dedicated-admin$", 16 | "^openshift-addon-operator$", 17 | "^openshift-aqua$", 18 | "^openshift-aws-vpce-operator$", 19 | "^openshift-backplane$", 20 | "^openshift-backplane-cee$", 21 | "^openshift-backplane-csa$", 22 | "^openshift-backplane-cse$", 23 | "^openshift-backplane-csm$", 24 | "^openshift-backplane-managed-scripts$", 25 | "^openshift-backplane-mobb$", 26 | "^openshift-backplane-srep$", 27 | "^openshift-backplane-tam$", 28 | "^openshift-cloud-ingress-operator$", 29 | "^openshift-codeready-workspaces$", 30 | "^openshift-compliance$", 31 | "^openshift-compliance-monkey$", 32 | "^openshift-container-security$", 33 | "^openshift-custom-domains-operator$", 34 | "^openshift-customer-monitoring$", 35 | "^openshift-deployment-validation-operator$", 36 | "^openshift-managed-node-metadata-operator$", 37 | "^openshift-file-integrity$", 38 | "^openshift-logging$", 39 | "^openshift-managed-upgrade-operator$", 40 | "^openshift-must-gather-operator$", 41 | "^openshift-observability-operator$", 42 | "^openshift-ocm-agent-operator$", 43 | "^openshift-operators-redhat$", 44 | "^openshift-osd-metrics$", 45 | "^openshift-rbac-permissions$", 46 | "^openshift-route-monitor-operator$", 47 | "^openshift-scanning$", 48 | "^openshift-security$", 49 | "^openshift-splunk-forwarder-operator$", 50 | "^openshift-sre-pruning$", 51 | "^openshift-suricata$", 52 | "^openshift-validation-webhook$", 53 | "^openshift-velero$", 54 | "^openshift-monitoring$", 55 | "^openshift$", 56 | "^openshift-cluster-version$", 57 | "^goalert$", 58 | "^keycloak$", 59 | "^configure-goalert-operator$", 60 | "^kube-system$", 61 | "^openshift-apiserver$", 62 | "^openshift-apiserver-operator$", 63 | "^openshift-authentication$", 64 | "^openshift-authentication-operator$", 65 | "^openshift-cloud-controller-manager$", 66 | "^openshift-cloud-controller-manager-operator$", 67 | "^openshift-cloud-credential-operator$", 68 | "^openshift-cloud-network-config-controller$", 69 | "^openshift-cluster-api$", 70 | "^openshift-cluster-csi-drivers$", 71 | "^openshift-cluster-machine-approver$", 72 | "^openshift-cluster-node-tuning-operator$", 73 | "^openshift-cluster-samples-operator$", 74 | "^openshift-cluster-storage-operator$", 75 | "^openshift-config$", 76 | "^openshift-config-managed$", 77 | "^openshift-config-operator$", 78 | "^openshift-console$", 79 | "^openshift-console-operator$", 80 | "^openshift-console-user-settings$", 81 | "^openshift-controller-manager$", 82 | "^openshift-controller-manager-operator$", 83 | "^openshift-dns$", 84 | "^openshift-dns-operator$", 85 | "^openshift-etcd$", 86 | "^openshift-etcd-operator$", 87 | "^openshift-host-network$", 88 | "^openshift-image-registry$", 89 | "^openshift-ingress$", 90 | "^openshift-ingress-canary$", 91 | "^openshift-ingress-operator$", 92 | "^openshift-insights$", 93 | "^openshift-kni-infra$", 94 | "^openshift-kube-apiserver$", 95 | "^openshift-kube-apiserver-operator$", 96 | "^openshift-kube-controller-manager$", 97 | "^openshift-kube-controller-manager-operator$", 98 | "^openshift-kube-scheduler$", 99 | "^openshift-kube-scheduler-operator$", 100 | "^openshift-kube-storage-version-migrator$", 101 | "^openshift-kube-storage-version-migrator-operator$", 102 | "^openshift-machine-api$", 103 | "^openshift-machine-config-operator$", 104 | "^openshift-marketplace$", 105 | "^openshift-monitoring$", 106 | "^openshift-multus$", 107 | "^openshift-network-diagnostics$", 108 | "^openshift-network-operator$", 109 | "^openshift-nutanix-infra$", 110 | "^openshift-oauth-apiserver$", 111 | "^openshift-openstack-infra$", 112 | "^openshift-operator-lifecycle-manager$", 113 | "^openshift-operators$", 114 | "^openshift-ovirt-infra$", 115 | "^openshift-sdn$", 116 | "^openshift-ovn-kubernetes$", 117 | "^openshift-platform-operators$", 118 | "^openshift-route-controller-manager$", 119 | "^openshift-service-ca$", 120 | "^openshift-service-ca-operator$", 121 | "^openshift-user-workload-monitoring$", 122 | "^openshift-vsphere-infra$", 123 | } 124 | -------------------------------------------------------------------------------- /pkg/dispatcher/dispatcher.go: -------------------------------------------------------------------------------- 1 | package dispatcher 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "sync" 8 | 9 | logf "sigs.k8s.io/controller-runtime/pkg/log" 10 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 11 | 12 | responsehelper "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/helpers" 13 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks" 14 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 15 | ) 16 | 17 | var log = logf.Log.WithName("dispatcher") 18 | 19 | // Dispatcher struct 20 | type Dispatcher struct { 21 | hooks *map[string]webhooks.WebhookFactory // uri -> hookfactory 22 | mu sync.Mutex 23 | } 24 | 25 | // NewDispatcher new dispatcher 26 | func NewDispatcher(hooks webhooks.RegisteredWebhooks) *Dispatcher { 27 | hookMap := make(map[string]webhooks.WebhookFactory) 28 | for _, hook := range hooks { 29 | hookMap[hook().GetURI()] = hook 30 | } 31 | return &Dispatcher{ 32 | hooks: &hookMap, 33 | } 34 | } 35 | 36 | // HandleRequest http request 37 | // HTTP status code usage: When the request body is correctly parsed into a 38 | // request (utils.ParseHTTPRequest) then we should always send 200 OK and use 39 | // the response body (response.status.code) to indicate a problem. When instead 40 | // there's a problem with the HTTP request itself (404, an inability to parse a 41 | // request, or some internal problem) it is appropriate to use the HTTP status 42 | // code to communicate. 43 | func (d *Dispatcher) HandleRequest(w http.ResponseWriter, r *http.Request) { 44 | d.mu.Lock() 45 | defer d.mu.Unlock() 46 | log.Info("Handling request", "request", r.RequestURI) 47 | url, err := url.Parse(r.RequestURI) 48 | if err != nil { 49 | w.WriteHeader(http.StatusBadRequest) 50 | log.Error(err, "Couldn't parse request %s", r.RequestURI) 51 | responsehelper.SendResponse(w, admissionctl.Errored(http.StatusBadRequest, err)) 52 | return 53 | } 54 | 55 | // is it one of ours? 56 | if hook, ok := (*d.hooks)[url.Path]; ok { 57 | // it's one of ours, so let's attempt to parse the request 58 | request, _, err := utils.ParseHTTPRequest(r) 59 | // Problem even parsing an AdmissionReview, so use HTTP status code 60 | if err != nil { 61 | w.WriteHeader(http.StatusBadRequest) 62 | log.Error(err, "Error parsing HTTP Request Body") 63 | responsehelper.SendResponse(w, admissionctl.Errored(http.StatusBadRequest, err)) 64 | return 65 | } 66 | // Valid AdmissionReview, but we can't do anything with it because we do not 67 | // think the request inside is valid. 68 | if !hook().Validate(request) { 69 | err = fmt.Errorf("not a valid webhook request") 70 | log.Error(err, "Error validaing HTTP Request Body") 71 | responsehelper.SendResponse(w, 72 | admissionctl.Errored(http.StatusBadRequest, err)) 73 | return 74 | } 75 | 76 | // Dispatch 77 | responsehelper.SendResponse(w, hook().Authorized(request)) 78 | return 79 | } 80 | log.Info("Request is not for a registered webhook.", "known_hooks", *d.hooks, "parsed_url", url, "lookup", (*d.hooks)[url.Path]) 81 | // Not a registered hook 82 | // Note: This segment is not likely to be reached because there will not be 83 | // any URI registered (handler set up) for an URI that would trigger this. 84 | w.WriteHeader(404) 85 | responsehelper.SendResponse(w, 86 | admissionctl.Errored(http.StatusBadRequest, 87 | fmt.Errorf("request is not for a registered webhook"))) 88 | } 89 | -------------------------------------------------------------------------------- /pkg/helpers/response.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | admissionapi "k8s.io/api/admission/v1" 9 | logf "sigs.k8s.io/controller-runtime/pkg/log" 10 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 11 | ) 12 | 13 | var log = logf.Log.WithName("response_helper") 14 | 15 | // SendResponse Send the AdmissionReview. 16 | func SendResponse(w io.Writer, resp admissionctl.Response) { 17 | 18 | // Apply ownership annotation to allow for granular alerts for 19 | // manipulation of SREP owned webhooks. 20 | resp.AuditAnnotations = map[string]string{ 21 | "owner": "srep-managed-webhook", 22 | } 23 | 24 | encoder := json.NewEncoder(w) 25 | responseAdmissionReview := admissionapi.AdmissionReview{ 26 | Response: &resp.AdmissionResponse, 27 | } 28 | responseAdmissionReview.APIVersion = admissionapi.SchemeGroupVersion.String() 29 | responseAdmissionReview.Kind = "AdmissionReview" 30 | err := encoder.Encode(responseAdmissionReview) 31 | // TODO (lisa): handle this in a non-recursive way (why would the second one succeed)? 32 | if err != nil { 33 | log.Error(err, "Failed to encode Response", "response", resp) 34 | SendResponse(w, admissionctl.Errored(http.StatusInternalServerError, err)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pkg/helpers/response_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "testing" 9 | 10 | admissionapi "k8s.io/api/admission/v1" 11 | "k8s.io/apimachinery/pkg/types" 12 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 13 | ) 14 | 15 | func makeBuffer() *bytes.Buffer { 16 | return new(bytes.Buffer) 17 | } 18 | 19 | func formatOutput(s string) string { 20 | return fmt.Sprintf("%s\n", s) 21 | } 22 | 23 | func makeResponseObj(uid string, allowed bool, e error) *admissionctl.Response { 24 | if e == nil { 25 | return &admissionctl.Response{ 26 | AdmissionResponse: admissionapi.AdmissionResponse{ 27 | UID: types.UID(uid), 28 | Allowed: allowed, 29 | }, 30 | } 31 | } else { 32 | n := admissionctl.Errored(http.StatusBadRequest, e) 33 | return &n 34 | } 35 | } 36 | 37 | func TestBadResponse(t *testing.T) { 38 | t.Skip("Not quite sure how to test json encoding error") 39 | } 40 | 41 | func TestResponse(t *testing.T) { 42 | tests := []struct { 43 | allowed bool 44 | uid string 45 | e error 46 | status int32 47 | expectedResult string 48 | }{ 49 | { 50 | allowed: true, 51 | uid: "test-uid", 52 | e: nil, 53 | status: http.StatusOK, 54 | // the writer sends a newline 55 | expectedResult: formatOutput(`{"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1","response":{"uid":"test-uid","allowed":true,"auditAnnotations":{"owner":"srep-managed-webhook"}}}`), 56 | }, 57 | { 58 | allowed: false, 59 | uid: "test-fail-with-error", 60 | e: fmt.Errorf("request body is empty"), 61 | status: http.StatusBadRequest, 62 | expectedResult: formatOutput(`{"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1","response":{"uid":"","allowed":false,"status":{"metadata":{},"message":"request body is empty","code":400},"auditAnnotations":{"owner":"srep-managed-webhook"}}}`), 63 | }, 64 | } 65 | for _, test := range tests { 66 | buf := makeBuffer() 67 | respObj := makeResponseObj(test.uid, test.allowed, test.e) 68 | SendResponse(buf, *respObj) 69 | if buf.String() != test.expectedResult { 70 | t.Fatalf("Expected to have `%s` but got `%s`", test.expectedResult, buf.String()) 71 | } 72 | decodedResult := &admissionapi.AdmissionReview{} 73 | err := json.Unmarshal([]byte(buf.String()), decodedResult) 74 | if err != nil { 75 | t.Errorf("Couldn't unmarshal the JSON blob: %s", err.Error()) 76 | } 77 | t.Logf("Response body = %s", buf.String()) 78 | 79 | if test.e != nil { 80 | if test.status == http.StatusOK { 81 | t.Errorf("It is weird to have an error result and a 200 OK. Check test's status field.") 82 | } 83 | // check for the Response.Result 84 | if decodedResult.Response.Result == nil { 85 | t.Fatalf("Error responses need a Response.Result, and this one didn't have one") 86 | } else { 87 | if decodedResult.Response.Result.Code != test.status { 88 | t.Fatalf("Expected HTTP status code of the Result to be %d, but got %d instead", test.status, decodedResult.Response.Result.Code) 89 | } 90 | } 91 | } 92 | 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /pkg/k8sutil/k8sutil.go: -------------------------------------------------------------------------------- 1 | package k8sutil 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "k8s.io/apimachinery/pkg/runtime" 9 | "k8s.io/client-go/rest" 10 | "k8s.io/client-go/tools/clientcmd" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | logf "sigs.k8s.io/controller-runtime/pkg/log" 13 | ) 14 | 15 | type RunModeType string 16 | 17 | const ( 18 | LocalRunMode RunModeType = "local" 19 | ClusterRunMode RunModeType = "cluster" 20 | 21 | OperatorNameEnvVar = "OPERATOR_NAME" 22 | ) 23 | 24 | var ( 25 | log = logf.Log.WithName("k8sutil") 26 | 27 | ForceRunModeEnv = "OSDK_FORCE_RUN_MODE" 28 | ErrNoNamespace = fmt.Errorf("namespace not found for current environment") 29 | ErrRunLocal = fmt.Errorf("operator run mode forced to local") 30 | ) 31 | 32 | func buildConfig(kubeconfig string) (*rest.Config, error) { 33 | // Try loading KUBECONFIG env var. If not set fallback on InClusterConfig 34 | 35 | if kubeconfig != "" { 36 | cfg, err := clientcmd.BuildConfigFromFlags("", kubeconfig) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return cfg, nil 41 | } 42 | 43 | cfg, err := rest.InClusterConfig() 44 | if err != nil { 45 | return nil, err 46 | } 47 | return cfg, nil 48 | } 49 | 50 | // KubeClient creates a new kubeclient that interacts with the Kube api with the service account secrets 51 | func KubeClient(s *runtime.Scheme) (client.Client, error) { 52 | // Try loading KUBECONFIG env var. Else falls back on in-cluster config 53 | config, err := buildConfig(os.Getenv("KUBECONFIG")) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | c, err := client.New(config, client.Options{ 59 | Scheme: s, 60 | }) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return c, nil 65 | } 66 | 67 | func isRunModeLocal() bool { 68 | return os.Getenv(ForceRunModeEnv) == string(LocalRunMode) 69 | } 70 | 71 | // GetOperatorNamespace returns the namespace the operator should be running in. 72 | func GetOperatorNamespace() (string, error) { 73 | if isRunModeLocal() { 74 | return "", ErrRunLocal 75 | } 76 | nsBytes, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") 77 | if err != nil { 78 | if os.IsNotExist(err) { 79 | return "", ErrNoNamespace 80 | } 81 | return "", err 82 | } 83 | ns := strings.TrimSpace(string(nsBytes)) 84 | log.V(1).Info("Found namespace", "Namespace", ns) 85 | return ns, nil 86 | } 87 | 88 | // GetOperatorName return the operator name 89 | func GetOperatorName() (string, error) { 90 | operatorName, found := os.LookupEnv(OperatorNameEnvVar) 91 | if !found { 92 | return "", fmt.Errorf("%s must be set", OperatorNameEnvVar) 93 | } 94 | if len(operatorName) == 0 { 95 | return "", fmt.Errorf("%s must not be empty", OperatorNameEnvVar) 96 | } 97 | return operatorName, nil 98 | } 99 | -------------------------------------------------------------------------------- /pkg/localmetrics/localmetrics.go: -------------------------------------------------------------------------------- 1 | package localmetrics 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | ) 6 | 7 | var ( 8 | MetricNodeWebhookBlockedReqeust = prometheus.NewCounterVec(prometheus.CounterOpts{ 9 | Name: "managed_webhook_node_blocked_request", 10 | Help: "Report how many times the managed node webhook has blocked requests", 11 | }, []string{"user"}) 12 | 13 | MetricsList = []prometheus.Collector{ 14 | MetricNodeWebhookBlockedReqeust, 15 | } 16 | ) 17 | 18 | func IncrementNodeWebhookBlockedRequest(user string) { 19 | MetricNodeWebhookBlockedReqeust.With(prometheus.Labels{"user": user}).Inc() 20 | } 21 | -------------------------------------------------------------------------------- /pkg/syncset/syncsetbylabelselector.go: -------------------------------------------------------------------------------- 1 | // Package syncset provides a type to map LabelSelectors to arbitrary objects 2 | // and render the minimal set of SelectorSyncSets based on the LabelSelectors. 3 | // The idea is to use it as a replacement for map[metav1.LabelSelector]runtime.RawExtension. 4 | // A map cannot be used because metav1.LabelSelector cannot be used as a key in a map. 5 | // This implementation uses reflect.DeepEqual to compare map keys. 6 | package syncset 7 | 8 | import ( 9 | "encoding/json" 10 | "fmt" 11 | "os" 12 | "reflect" 13 | 14 | admissionregv1 "k8s.io/api/admissionregistration/v1" 15 | v1 "k8s.io/api/apps/v1" 16 | 17 | hivev1 "github.com/openshift/hive/apis/hive/v1" 18 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 19 | "k8s.io/apimachinery/pkg/runtime" 20 | ) 21 | 22 | // SyncSetResourcesByLabelSelector is a mapping data structure. 23 | // It uses metav1.LabelSelector as key and runtime.RawExtension as value. 24 | // The builtin map type cannot be used because metav1.LabelSelector cannot be used as key. 25 | type SyncSetResourcesByLabelSelector struct { 26 | entries []mapEntry 27 | } 28 | 29 | type mapEntry struct { 30 | key metav1.LabelSelector 31 | values []runtime.RawExtension 32 | } 33 | 34 | // Add adds a resources to a SyncSetResourcesByLabelSelector object 35 | func (s *SyncSetResourcesByLabelSelector) Add(key metav1.LabelSelector, object runtime.RawExtension) { 36 | existingEntry := s.Get(key) 37 | 38 | if existingEntry != nil { 39 | existingEntry.values = append(existingEntry.values, object) 40 | return 41 | } 42 | 43 | s.entries = append(s.entries, mapEntry{key, []runtime.RawExtension{object}}) 44 | } 45 | 46 | // Get returns a single entry based on the passed key. If none exists, it returns nil 47 | func (s *SyncSetResourcesByLabelSelector) Get(key metav1.LabelSelector) *mapEntry { 48 | for i, entry := range s.entries { 49 | if reflect.DeepEqual(entry.key, key) { 50 | return &s.entries[i] 51 | } 52 | } 53 | return nil 54 | } 55 | 56 | // RenderSelectorSyncSets renders a minimal set of SelectorSyncSets based on the LabelSelectors 57 | // existing in the SyncSetResourcesByLabelSelector object 58 | func (s *SyncSetResourcesByLabelSelector) RenderSelectorSyncSets(labels map[string]string) []runtime.RawExtension { 59 | sss := []runtime.RawExtension{} 60 | for i, entry := range s.entries { 61 | sss = append(sss, runtime.RawExtension{ 62 | Raw: Encode(createSelectorSyncSet( 63 | fmt.Sprintf("managed-cluster-validating-webhooks-%d", i), 64 | entry.values, 65 | entry.key, 66 | labels, 67 | ), 68 | ), 69 | }) 70 | } 71 | return sss 72 | } 73 | 74 | func createSelectorSyncSet(name string, resources []runtime.RawExtension, selector metav1.LabelSelector, labels map[string]string) *hivev1.SelectorSyncSet { 75 | return &hivev1.SelectorSyncSet{ 76 | TypeMeta: metav1.TypeMeta{ 77 | Kind: "SelectorSyncSet", 78 | APIVersion: "hive.openshift.io/v1", 79 | }, 80 | ObjectMeta: metav1.ObjectMeta{ 81 | Name: name, 82 | Labels: labels, 83 | }, 84 | Spec: hivev1.SelectorSyncSetSpec{ 85 | SyncSetCommonSpec: hivev1.SyncSetCommonSpec{ 86 | ResourceApplyMode: hivev1.SyncResourceApplyMode, 87 | Resources: resources, 88 | }, 89 | ClusterDeploymentSelector: selector, 90 | }, 91 | } 92 | } 93 | 94 | func Encode(obj interface{}) []byte { 95 | o, err := json.Marshal(obj) 96 | if err != nil { 97 | fmt.Printf("Error encoding %+v\n", obj) 98 | os.Exit(1) 99 | } 100 | return o 101 | } 102 | 103 | // This is needed to override the omitempty on serviceAccount and serviceAccountName 104 | // which otherwise means we can't nullify them in the SelectorSyncSet 105 | func EncodeAndFixDaemonset(ds *v1.DaemonSet) ([]byte, error) { 106 | 107 | // Convert to json 108 | o, err := json.Marshal(ds) 109 | 110 | // explicitly set serviceAccount / serviceAccountName to emptystring 111 | var decoded interface{} 112 | json.Unmarshal(o, &decoded) 113 | 114 | // set the serviceAccount/serviceAccountName to emptystring 115 | // only empty-set serviceAccountName if it's not already defined 116 | if len(ds.Spec.Template.Spec.ServiceAccountName) == 0 { 117 | decoded.(map[string]interface{})["spec"].(map[string]interface{})["template"].(map[string]interface{})["spec"].(map[string]interface{})["serviceAccountName"] = "" 118 | } 119 | // serviceAccount is deprecated 120 | decoded.(map[string]interface{})["spec"].(map[string]interface{})["template"].(map[string]interface{})["spec"].(map[string]interface{})["serviceAccount"] = "" 121 | 122 | // convert back to json 123 | r, err := json.Marshal(decoded) 124 | if err != nil { 125 | return nil, fmt.Errorf("Error encoding %+v\n", decoded) 126 | } 127 | return r, nil 128 | 129 | } 130 | 131 | func EncodeValidatingAndFixCA(vw admissionregv1.ValidatingWebhookConfiguration) ([]byte, error) { 132 | 133 | // Get the existing caBundle value 134 | if len(vw.Webhooks) < 1 { 135 | return nil, fmt.Errorf("Require at least one webhook") 136 | } 137 | caBundleValue := string(vw.Webhooks[0].ClientConfig.CABundle) 138 | 139 | // Convert to json 140 | o, err := json.Marshal(vw) 141 | if caBundleValue == "" { 142 | return o, err 143 | } 144 | 145 | // fix broken CABundle setting here 146 | var decoded interface{} 147 | json.Unmarshal(o, &decoded) 148 | 149 | // set the CA 150 | decoded.(map[string]interface{})["webhooks"].([]interface{})[0].(map[string]interface{})["clientConfig"].(map[string]interface{})["caBundle"] = caBundleValue 151 | 152 | // convert back to json 153 | r, err := json.Marshal(decoded) 154 | if err != nil { 155 | return nil, fmt.Errorf("Error encoding %+v\n", decoded) 156 | } 157 | return r, nil 158 | } 159 | 160 | func EncodeMutatingAndFixCA(vw admissionregv1.MutatingWebhookConfiguration) ([]byte, error) { 161 | 162 | // Get the existing caBundle value 163 | if len(vw.Webhooks) < 1 { 164 | return nil, fmt.Errorf("Require at least one webhook") 165 | } 166 | caBundleValue := string(vw.Webhooks[0].ClientConfig.CABundle) 167 | 168 | // Convert to json 169 | o, err := json.Marshal(vw) 170 | if caBundleValue == "" { 171 | return o, err 172 | } 173 | 174 | // fix broken CABundle setting here 175 | var decoded interface{} 176 | json.Unmarshal(o, &decoded) 177 | 178 | // set the CA 179 | decoded.(map[string]interface{})["webhooks"].([]interface{})[0].(map[string]interface{})["clientConfig"].(map[string]interface{})["caBundle"] = caBundleValue 180 | 181 | // convert back to json 182 | r, err := json.Marshal(decoded) 183 | if err != nil { 184 | return nil, fmt.Errorf("Error encoding %+v\n", decoded) 185 | } 186 | return r, nil 187 | } 188 | -------------------------------------------------------------------------------- /pkg/testutils/testutils.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | 9 | admissionv1 "k8s.io/api/admission/v1" 10 | authenticationv1 "k8s.io/api/authentication/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | "k8s.io/apimachinery/pkg/types" 14 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 15 | 16 | responsehelper "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/helpers" 17 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 18 | ) 19 | 20 | // Webhook interface 21 | type Webhook interface { 22 | // Authorized will determine if the request is allowed 23 | Authorized(request admissionctl.Request) admissionctl.Response 24 | } 25 | 26 | // CanCanNot helper to make English a bit nicer 27 | func CanCanNot(b bool) string { 28 | if b { 29 | return "can" 30 | } 31 | return "can not" 32 | } 33 | 34 | // CreateFakeRequestJSON will render the []byte slice needed for the (fake) HTTP request. 35 | // Inputs into this are the request UID, which GVK and GVR are being gated by this webhook, 36 | // User information (username and groups), what kind of operation is being gated by this webhook 37 | // and finally the runtime.RawExtension representation of the request's Object or OldObject 38 | // The Object/OldObject is automatically inferred by the operation; delete operations will force OldObject 39 | // To create the RawExtension: 40 | // 41 | // obj := runtime.RawExtension{ 42 | // Raw: []byte(rawObjString), 43 | // } 44 | // 45 | // where rawObjString is a literal JSON blob, eg: 46 | // 47 | // { 48 | // "metadata": { 49 | // "name": "namespace-name", 50 | // "uid": "request-userid", 51 | // "creationTimestamp": "2020-05-10T07:51:00Z" 52 | // }, 53 | // "users": null 54 | // } 55 | func CreateFakeRequestJSON(uid string, 56 | gvk metav1.GroupVersionKind, gvr metav1.GroupVersionResource, 57 | operation admissionv1.Operation, 58 | username string, userGroups []string, namespace string, 59 | obj, oldObject *runtime.RawExtension) ([]byte, error) { 60 | 61 | req := admissionv1.AdmissionReview{ 62 | Request: &admissionv1.AdmissionRequest{ 63 | UID: types.UID(uid), 64 | Kind: gvk, 65 | RequestKind: &gvk, 66 | Resource: gvr, 67 | Operation: operation, 68 | Namespace: namespace, 69 | UserInfo: authenticationv1.UserInfo{ 70 | Username: username, 71 | Groups: userGroups, 72 | }, 73 | }, 74 | } 75 | switch operation { 76 | case admissionv1.Create: 77 | req.Request.Object = *obj 78 | case admissionv1.Update: 79 | // TODO (lisa): Update should have a different object for Object than for OldObject 80 | req.Request.Object = *obj 81 | if oldObject != nil { 82 | req.Request.OldObject = *oldObject 83 | } else { 84 | req.Request.OldObject = *obj 85 | } 86 | case admissionv1.Delete: 87 | req.Request.OldObject = *obj 88 | } 89 | b, err := json.Marshal(req) 90 | if err != nil { 91 | return []byte{}, err 92 | } 93 | return b, nil 94 | } 95 | 96 | // CreateHTTPRequest takes all the information needed for an AdmissionReview. 97 | // See also CreateFakeRequestJSON for more. 98 | func CreateHTTPRequest(uri, uid string, 99 | gvk metav1.GroupVersionKind, gvr metav1.GroupVersionResource, 100 | operation admissionv1.Operation, 101 | username string, userGroups []string, namespace string, 102 | obj, oldObject *runtime.RawExtension) (*http.Request, error) { 103 | req, err := CreateFakeRequestJSON(uid, gvk, gvr, operation, username, userGroups, namespace, obj, oldObject) 104 | if err != nil { 105 | return nil, err 106 | } 107 | buf := bytes.NewBuffer(req) 108 | httprequest := httptest.NewRequest("POST", uri, buf) 109 | httprequest.Header["Content-Type"] = []string{"application/json"} 110 | return httprequest, nil 111 | } 112 | 113 | // SendHTTPRequest will send the fake request to be handled by the Webhook 114 | func SendHTTPRequest(req *http.Request, s Webhook) (*admissionv1.AdmissionResponse, error) { 115 | httpResponse := httptest.NewRecorder() 116 | request, _, err := utils.ParseHTTPRequest(req) 117 | if err != nil { 118 | return nil, err 119 | } 120 | resp := s.Authorized(request) 121 | responsehelper.SendResponse(httpResponse, resp) 122 | // at this popint, httpResponse should contain the data sent in response to the webhook query, which is the success/fail 123 | ret := &admissionv1.AdmissionReview{} 124 | err = json.Unmarshal(httpResponse.Body.Bytes(), ret) 125 | if err != nil { 126 | return nil, err 127 | } 128 | return ret.Response, nil 129 | } 130 | -------------------------------------------------------------------------------- /pkg/webhooks/add_clusterlogging.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/clusterlogging" 5 | ) 6 | 7 | func init() { 8 | Register(clusterlogging.WebhookName, func() Webhook { return clusterlogging.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_clusterrolebinding.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/clusterrolebinding" 5 | ) 6 | 7 | func init() { 8 | Register(clusterrolebinding.WebhookName, func() Webhook { return clusterrolebinding.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_customresourcedefinitions.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/customresourcedefinitions" 5 | ) 6 | 7 | func init() { 8 | Register(customresourcedefinitions.WebhookName, func() Webhook { return customresourcedefinitions.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_hiveownership.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/hiveownership" 5 | ) 6 | 7 | func init() { 8 | Register(hiveownership.WebhookName, func() Webhook { return hiveownership.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_imagecontentpolicies.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/imagecontentpolicies" 5 | ) 6 | 7 | func init() { 8 | Register(imagecontentpolicies.WebhookName, func() Webhook { return imagecontentpolicies.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_ingressconfig_hook.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/ingressconfig" 5 | ) 6 | 7 | func init() { 8 | Register(ingressconfig.WebhookName, func() Webhook { return ingressconfig.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_ingresscontroller.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/ingresscontroller" 5 | ) 6 | 7 | func init() { 8 | Register(ingresscontroller.WebhookName, func() Webhook { return ingresscontroller.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_namespace_hook.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/namespace" 5 | ) 6 | 7 | func init() { 8 | Register(namespace.WebhookName, func() Webhook { return namespace.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_networkpolicy.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/networkpolicies" 5 | ) 6 | 7 | func init() { 8 | Register(networkpolicies.WebhookName, func() Webhook { return networkpolicies.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_node.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/node" 4 | 5 | func init() { 6 | Register(node.WebhookName, func() Webhook { return node.NewWebhook() }) 7 | } 8 | -------------------------------------------------------------------------------- /pkg/webhooks/add_pod.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/pod" 5 | ) 6 | 7 | func init() { 8 | Register(pod.WebhookName, func() Webhook { return pod.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_podimagespec.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/podimagespec" 5 | ) 6 | 7 | func init() { 8 | Register(podimagespec.WebhookName, func() Webhook { return podimagespec.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_prometheusrule.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/prometheusrule" 5 | ) 6 | 7 | func init() { 8 | Register(prometheusrule.WebhookName, func() Webhook { return prometheusrule.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_regularuser.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/regularuser/common" 5 | ) 6 | 7 | func init() { 8 | Register(common.WebhookName, func() Webhook { return common.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_scc.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/scc" 5 | ) 6 | 7 | func init() { 8 | Register(scc.WebhookName, func() Webhook { return scc.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_sdnmigration.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/sdnmigration" 5 | ) 6 | 7 | func init() { 8 | Register(sdnmigration.WebhookName, func() Webhook { return sdnmigration.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_service_hook.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/service" 5 | ) 6 | 7 | func init() { 8 | Register(service.WebhookName, func() Webhook { return service.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_serviceaccount.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/serviceaccount" 5 | ) 6 | 7 | func init() { 8 | Register(serviceaccount.WebhookName, func() Webhook { return serviceaccount.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/add_techpreviewnoupgrade.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/techpreviewnoupgrade" 5 | ) 6 | 7 | func init() { 8 | Register(techpreviewnoupgrade.WebhookName, func() Webhook { return techpreviewnoupgrade.NewWebhook() }) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/webhooks/clusterlogging/clusterlogging_test.go: -------------------------------------------------------------------------------- 1 | package clusterlogging_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | admissionv1 "k8s.io/api/admission/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | 11 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/testutils" 12 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/clusterlogging" 13 | ) 14 | 15 | type clusterloggingTestSuite struct { 16 | testName string 17 | testID string 18 | username string 19 | userGroups []string 20 | oldObject *runtime.RawExtension 21 | operation admissionv1.Operation 22 | appMaxAge string 23 | infraMaxAge string 24 | auditMaxAge string 25 | shouldBeAllowed bool 26 | } 27 | 28 | const testObjectRaw string = ` 29 | { 30 | "apiVersion": "logging.openshift.io/v1", 31 | "kind": "ClusterLogging", 32 | "metadata": { 33 | "name": "test-subject", 34 | "uid": "1234", 35 | "creationTimestamp": "2020-05-10T07:51:00Z", 36 | "labels": {} 37 | }, 38 | "spec": { 39 | "managementState": "Managed" , 40 | "logStore": { 41 | "type": "elasticsearch", 42 | "retentionPolicy": { 43 | "application": { 44 | "maxAge": "%s" 45 | }, 46 | "infra": { 47 | "maxAge": "%s" 48 | }, 49 | "audit": { 50 | "maxAge": "%s" 51 | } 52 | } 53 | } 54 | } 55 | }` 56 | 57 | func NewTestSuite(appMaxAge, infraMaxAge, auditMaxAge string) clusterloggingTestSuite { 58 | return clusterloggingTestSuite{ 59 | testID: "1234", 60 | operation: admissionv1.Create, 61 | appMaxAge: appMaxAge, 62 | infraMaxAge: infraMaxAge, 63 | auditMaxAge: auditMaxAge, 64 | shouldBeAllowed: true, 65 | } 66 | } 67 | 68 | func (s clusterloggingTestSuite) ExpectNotAllowed() clusterloggingTestSuite { 69 | s.shouldBeAllowed = false 70 | return s 71 | } 72 | 73 | func createOldObject(appMaxAge, infraMaxAge, auditMaxAge string) *runtime.RawExtension { 74 | return &runtime.RawExtension{ 75 | Raw: []byte(createRawJSONString(appMaxAge, infraMaxAge, auditMaxAge)), 76 | } 77 | } 78 | 79 | func createRawJSONString(appMaxAge, infraMaxAge, auditMaxAge string) string { 80 | s := fmt.Sprintf(testObjectRaw, appMaxAge, infraMaxAge, auditMaxAge) 81 | return s 82 | } 83 | 84 | func Test_InvalidTimeUnit(t *testing.T) { 85 | testSuites := []clusterloggingTestSuite{ 86 | NewTestSuite("7x", "1h", "1h").ExpectNotAllowed(), 87 | NewTestSuite("7D", "1h", "1h").ExpectNotAllowed(), 88 | NewTestSuite("7S", "1h", "1h").ExpectNotAllowed(), 89 | NewTestSuite("m", "1h", "1h").ExpectNotAllowed(), 90 | } 91 | 92 | runTests(t, testSuites) 93 | } 94 | 95 | func Test_RetentionPeriodNotAllowed(t *testing.T) { 96 | testSuites := []clusterloggingTestSuite{ 97 | NewTestSuite("8d", "1h", "1h").ExpectNotAllowed(), 98 | NewTestSuite("169h", "1h", "1h").ExpectNotAllowed(), 99 | NewTestSuite("1h", "1m", "1h").ExpectNotAllowed(), 100 | NewTestSuite("1h", "1s", "1h").ExpectNotAllowed(), 101 | NewTestSuite("1h", "1h", "8d").ExpectNotAllowed(), 102 | NewTestSuite("7M", "1h", "1h").ExpectNotAllowed(), 103 | NewTestSuite("7M", "0h", "1h").ExpectNotAllowed(), 104 | NewTestSuite("7M", "1h", "0h").ExpectNotAllowed(), 105 | NewTestSuite("7M", "61m", "0h").ExpectNotAllowed(), 106 | NewTestSuite("7M", "60m", "61m").ExpectNotAllowed(), 107 | NewTestSuite("59m", "60m", "60m").ExpectNotAllowed(), 108 | NewTestSuite("1h", "59m", "60m").ExpectNotAllowed(), 109 | NewTestSuite("1h", "60m", "59m").ExpectNotAllowed(), 110 | } 111 | 112 | runTests(t, testSuites) 113 | } 114 | 115 | func Test_RetentionPeriodAllowed(t *testing.T) { 116 | testSuites := []clusterloggingTestSuite{ 117 | NewTestSuite("7d", "1h", "1h"), 118 | NewTestSuite("168h", "1h", "1h"), 119 | NewTestSuite("168h", "60m", "60m"), 120 | NewTestSuite("1h", "1h", "1h"), 121 | } 122 | 123 | runTests(t, testSuites) 124 | } 125 | 126 | func runTests(t *testing.T, tests []clusterloggingTestSuite) { 127 | for _, test := range tests { 128 | obj := createOldObject(test.appMaxAge, test.infraMaxAge, test.auditMaxAge) 129 | hook := clusterlogging.NewWebhook() 130 | httprequest, err := testutils.CreateHTTPRequest(hook.GetURI(), 131 | test.testID, 132 | metav1.GroupVersionKind{}, metav1.GroupVersionResource{}, test.operation, test.username, test.userGroups, "", obj, test.oldObject) 133 | if err != nil { 134 | t.Fatalf("Expected no error, got %s", err.Error()) 135 | } 136 | 137 | response, err := testutils.SendHTTPRequest(httprequest, hook) 138 | if err != nil { 139 | t.Fatalf("Expected no error, got %s", err.Error()) 140 | } 141 | if response.UID == "" { 142 | t.Fatalf("No tracking UID associated with the response: %+v", response) 143 | } 144 | 145 | if response.Allowed != test.shouldBeAllowed { 146 | t.Fatalf("Mismatch: %v %s %s. Test's expectation is that the user %s. Reason: %s, Message: %v", 147 | test, 148 | testutils.CanCanNot(response.Allowed), string(test.operation), 149 | testutils.CanCanNot(test.shouldBeAllowed), response.Result.Reason, response.Result.Message) 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /pkg/webhooks/customresourcedefinitions/customresourcedefinitions.go: -------------------------------------------------------------------------------- 1 | package customresourcedefinitions 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "regexp" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 11 | 12 | admissionregv1 "k8s.io/api/admissionregistration/v1" 13 | apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/runtime" 16 | logf "sigs.k8s.io/controller-runtime/pkg/log" 17 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 18 | ) 19 | 20 | const ( 21 | WebhookName string = "customresourcedefinitions-validation" 22 | docString string = `Managed OpenShift Customers may not change CustomResourceDefinitions managed by Red Hat.` 23 | ) 24 | 25 | var ( 26 | timeout int32 = 2 27 | allowedUsers = []string{"system:admin", "backplane-cluster-admin"} 28 | sreAdminGroups = []string{"system:serviceaccounts:openshift-backplane-srep"} 29 | privilegedServiceAccountGroupsRe = regexp.MustCompile(utils.PrivilegedServiceAccountGroups) 30 | scope = admissionregv1.ClusterScope 31 | rules = []admissionregv1.RuleWithOperations{ 32 | { 33 | Operations: []admissionregv1.OperationType{ 34 | admissionregv1.Create, 35 | admissionregv1.Update, 36 | admissionregv1.Delete, 37 | }, 38 | Rule: admissionregv1.Rule{ 39 | APIGroups: []string{"apiextensions.k8s.io"}, 40 | APIVersions: []string{"*"}, 41 | Resources: []string{"customresourcedefinitions"}, 42 | Scope: &scope, 43 | }, 44 | }, 45 | } 46 | log = logf.Log.WithName(WebhookName) 47 | ) 48 | 49 | // customresourcedefinitionsruleWebhook validates a customresourcedefinition change 50 | type customresourcedefinitionsruleWebhook struct { 51 | s runtime.Scheme 52 | } 53 | 54 | // NewWebhook creates the new webhook 55 | func NewWebhook() *customresourcedefinitionsruleWebhook { 56 | scheme := runtime.NewScheme() 57 | return &customresourcedefinitionsruleWebhook{ 58 | s: *scheme, 59 | } 60 | } 61 | 62 | // Authorized implements Webhook interface 63 | func (s *customresourcedefinitionsruleWebhook) Authorized(request admissionctl.Request) admissionctl.Response { 64 | //Implement authorized next 65 | return s.authorized(request) 66 | } 67 | 68 | func (s *customresourcedefinitionsruleWebhook) authorized(request admissionctl.Request) admissionctl.Response { 69 | var ret admissionctl.Response 70 | 71 | crd, err := s.renderCustomResourceDefinition(request) 72 | if err != nil { 73 | log.Error(err, "Could not render a CustomResourceDefinition from the incoming request") 74 | return admissionctl.Errored(http.StatusBadRequest, err) 75 | } 76 | 77 | if utils.IsProtectedByResourceName(crd.GetName()) { 78 | log.Info(fmt.Sprintf("%s operation detected on protected CustomResourceDefinition: %s", request.Operation, crd.Name)) 79 | if isAllowedUser(request) { 80 | ret = admissionctl.Allowed(fmt.Sprintf("User '%s' in group(s) '%s' can operate on CustomResourceDefinitions", request.UserInfo.Username, strings.Join(request.UserInfo.Groups, ", "))) 81 | ret.UID = request.AdmissionRequest.UID 82 | return ret 83 | } 84 | for _, group := range request.UserInfo.Groups { 85 | if privilegedServiceAccountGroupsRe.Match([]byte(group)) { 86 | ret = admissionctl.Allowed(fmt.Sprintf("Privileged service accounts in group(s) '%s' can operate on CustomResourceDefinitions", strings.Join(request.UserInfo.Groups, ", "))) 87 | ret.UID = request.AdmissionRequest.UID 88 | return ret 89 | } 90 | } 91 | 92 | ret = admissionctl.Denied(fmt.Sprintf("User '%s' prevented from accessing Red Mat managed resources. This is in an effort to prevent harmful actions that may cause unintended consequences or affect the stability of the cluster. If you have any questions about this, please reach out to Red Hat support at https://access.redhat.com/support", request.UserInfo.Username)) 93 | ret.UID = request.AdmissionRequest.UID 94 | return ret 95 | } 96 | 97 | log.Info("Allowing access", "request", request.AdmissionRequest) 98 | ret = admissionctl.Allowed("Non managed CustomResourceDefinition") 99 | ret.UID = request.AdmissionRequest.UID 100 | return ret 101 | } 102 | 103 | // isAllowedUser checks if the user or group is allowed to perform the action 104 | func isAllowedUser(request admissionctl.Request) bool { 105 | if slices.Contains(allowedUsers, request.UserInfo.Username) { 106 | return true 107 | } 108 | 109 | for _, group := range sreAdminGroups { 110 | if slices.Contains(request.UserInfo.Groups, group) { 111 | return true 112 | } 113 | } 114 | 115 | return false 116 | } 117 | 118 | func (s *customresourcedefinitionsruleWebhook) renderCustomResourceDefinition(req admissionctl.Request) (*apiextensionsv1.CustomResourceDefinition, error) { 119 | decoder := admissionctl.NewDecoder(&s.s) 120 | customResourceDefinition := &apiextensionsv1.CustomResourceDefinition{} 121 | 122 | var err error 123 | if len(req.OldObject.Raw) > 0 { 124 | err = decoder.DecodeRaw(req.OldObject, customResourceDefinition) 125 | } else { 126 | err = decoder.Decode(req, customResourceDefinition) 127 | } 128 | if err != nil { 129 | return nil, err 130 | } 131 | return customResourceDefinition, nil 132 | } 133 | 134 | // GetURI implements Webhook interface 135 | func (s *customresourcedefinitionsruleWebhook) GetURI() string { 136 | return "/" + WebhookName 137 | } 138 | 139 | // Validate implements Webhook interface 140 | func (s *customresourcedefinitionsruleWebhook) Validate(request admissionctl.Request) bool { 141 | valid := true 142 | valid = valid && (request.UserInfo.Username != "") 143 | valid = valid && (request.Kind.Kind == "CustomResourceDefinition") 144 | 145 | return valid 146 | } 147 | 148 | // Name implements Webhook interface 149 | func (s *customresourcedefinitionsruleWebhook) Name() string { 150 | return WebhookName 151 | } 152 | 153 | // FailurePolicy implements Webhook interface 154 | func (s *customresourcedefinitionsruleWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 155 | return admissionregv1.Ignore 156 | } 157 | 158 | // MatchPolicy implements Webhook interface 159 | func (s *customresourcedefinitionsruleWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 160 | return admissionregv1.Equivalent 161 | } 162 | 163 | // Rules implements Webhook interface 164 | func (s *customresourcedefinitionsruleWebhook) Rules() []admissionregv1.RuleWithOperations { 165 | return rules 166 | } 167 | 168 | // ObjectSelector implements Webhook interface 169 | func (s *customresourcedefinitionsruleWebhook) ObjectSelector() *metav1.LabelSelector { 170 | return nil 171 | } 172 | 173 | // SideEffects implements Webhook interface 174 | func (s *customresourcedefinitionsruleWebhook) SideEffects() admissionregv1.SideEffectClass { 175 | return admissionregv1.SideEffectClassNone 176 | } 177 | 178 | // TimeoutSeconds implements Webhook interface 179 | func (s *customresourcedefinitionsruleWebhook) TimeoutSeconds() int32 { 180 | return timeout 181 | } 182 | 183 | // Doc implements Webhook interface 184 | func (s *customresourcedefinitionsruleWebhook) Doc() string { 185 | return (docString) 186 | } 187 | 188 | // SyncSetLabelSelector returns the label selector to use in the SyncSet. 189 | // Return utils.DefaultLabelSelector() to stick with the default 190 | func (s *customresourcedefinitionsruleWebhook) SyncSetLabelSelector() metav1.LabelSelector { 191 | return utils.DefaultLabelSelector() 192 | } 193 | 194 | func (s *customresourcedefinitionsruleWebhook) ClassicEnabled() bool { return true } 195 | 196 | func (s *customresourcedefinitionsruleWebhook) HypershiftEnabled() bool { return false } 197 | -------------------------------------------------------------------------------- /pkg/webhooks/hiveownership/hiveownership.go: -------------------------------------------------------------------------------- 1 | package hiveownership 2 | 3 | import ( 4 | "os" 5 | "slices" 6 | "sync" 7 | 8 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 9 | admissionregv1 "k8s.io/api/admissionregistration/v1" 10 | admissionv1 "k8s.io/api/apps/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | logf "sigs.k8s.io/controller-runtime/pkg/log" 14 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 15 | ) 16 | 17 | // const 18 | const ( 19 | WebhookName string = "hiveownership-validation" 20 | docString string = `Managed OpenShift customers may not edit certain managed resources. A managed resource has a "hive.openshift.io/managed": "true" label.` 21 | ) 22 | 23 | // HiveOwnershipWebhook denies requests 24 | // if it made by a customer to manage hive-labeled resources 25 | type HiveOwnershipWebhook struct { 26 | mu sync.Mutex 27 | s runtime.Scheme 28 | } 29 | 30 | var ( 31 | privilegedUsers = []string{"kube:admin", "system:admin", "system:serviceaccount:kube-system:generic-garbage-collector", "backplane-cluster-admin"} 32 | adminGroups = []string{"system:serviceaccounts:openshift-backplane-srep"} 33 | 34 | log = logf.Log.WithName(WebhookName) 35 | 36 | scope = admissionregv1.ClusterScope 37 | rules = []admissionregv1.RuleWithOperations{ 38 | { 39 | Operations: []admissionregv1.OperationType{"UPDATE", "DELETE"}, 40 | Rule: admissionregv1.Rule{ 41 | APIGroups: []string{"quota.openshift.io"}, 42 | APIVersions: []string{"*"}, 43 | Resources: []string{"clusterresourcequotas"}, 44 | Scope: &scope, 45 | }, 46 | }, 47 | } 48 | ) 49 | 50 | // TimeoutSeconds implements Webhook interface 51 | func (s *HiveOwnershipWebhook) TimeoutSeconds() int32 { return 2 } 52 | 53 | // MatchPolicy implements Webhook interface 54 | func (s *HiveOwnershipWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 55 | return admissionregv1.Equivalent 56 | } 57 | 58 | // Name implements Webhook interface 59 | func (s *HiveOwnershipWebhook) Name() string { return WebhookName } 60 | 61 | // FailurePolicy implements Webhook interface 62 | func (s *HiveOwnershipWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 63 | return admissionregv1.Ignore 64 | } 65 | 66 | // Rules implements Webhook interface 67 | func (s *HiveOwnershipWebhook) Rules() []admissionregv1.RuleWithOperations { return rules } 68 | 69 | // GetURI implements Webhook interface 70 | func (s *HiveOwnershipWebhook) GetURI() string { return "/" + WebhookName } 71 | 72 | // SideEffects implements Webhook interface 73 | func (s *HiveOwnershipWebhook) SideEffects() admissionregv1.SideEffectClass { 74 | return admissionregv1.SideEffectClassNone 75 | } 76 | 77 | // Validate is the incoming request even valid? 78 | func (s *HiveOwnershipWebhook) Validate(req admissionctl.Request) bool { 79 | valid := true 80 | valid = valid && (req.UserInfo.Username != "") 81 | 82 | return valid 83 | } 84 | 85 | // Doc documents the hook 86 | func (s *HiveOwnershipWebhook) Doc() string { 87 | return docString 88 | } 89 | 90 | // ObjectSelector intercepts based on having the label 91 | // .metadata.labels["hive.openshift.io/managed"] == "true" 92 | func (s *HiveOwnershipWebhook) ObjectSelector() *metav1.LabelSelector { 93 | return &metav1.LabelSelector{ 94 | MatchLabels: map[string]string{ 95 | "hive.openshift.io/managed": "true", 96 | }, 97 | } 98 | } 99 | 100 | func (s *HiveOwnershipWebhook) authorized(request admissionctl.Request) admissionctl.Response { 101 | var ret admissionctl.Response 102 | 103 | // Admin users 104 | if slices.Contains(privilegedUsers, request.AdmissionRequest.UserInfo.Username) { 105 | ret = admissionctl.Allowed("Admin users may edit managed resources") 106 | ret.UID = request.AdmissionRequest.UID 107 | return ret 108 | } 109 | // Users in admin groups 110 | for _, group := range request.AdmissionRequest.UserInfo.Groups { 111 | if slices.Contains(adminGroups, group) { 112 | ret = admissionctl.Allowed("Members of admin group may edit managed resources") 113 | ret.UID = request.AdmissionRequest.UID 114 | return ret 115 | } 116 | } 117 | 118 | ret = admissionctl.Denied("Prevented from accessing Red Hat managed resources. This is in an effort to prevent harmful actions that may cause unintended consequences or affect the stability of the cluster. If you have any questions about this, please reach out to Red Hat support at https://access.redhat.com/support") 119 | ret.UID = request.AdmissionRequest.UID 120 | return ret 121 | } 122 | 123 | // Authorized implements Webhook interface 124 | func (s *HiveOwnershipWebhook) Authorized(request admissionctl.Request) admissionctl.Response { 125 | return s.authorized(request) 126 | } 127 | 128 | // CustomSelector implements Webhook interface, returning the custom label selector for the syncset, if any 129 | func (s *HiveOwnershipWebhook) SyncSetLabelSelector() metav1.LabelSelector { 130 | return utils.DefaultLabelSelector() 131 | } 132 | 133 | func (s *HiveOwnershipWebhook) ClassicEnabled() bool { return true } 134 | 135 | func (s *HiveOwnershipWebhook) HypershiftEnabled() bool { return false } 136 | 137 | // NewWebhook creates a new webhook 138 | func NewWebhook() *HiveOwnershipWebhook { 139 | scheme := runtime.NewScheme() 140 | err := admissionv1.AddToScheme(scheme) 141 | if err != nil { 142 | log.Error(err, "Fail adding admissionsv1 scheme to HiveOwnershipWebhook") 143 | os.Exit(1) 144 | } 145 | 146 | return &HiveOwnershipWebhook{ 147 | s: *scheme, 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /pkg/webhooks/hiveownership/hiveownership_test.go: -------------------------------------------------------------------------------- 1 | package hiveownership 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/testutils" 10 | 11 | admissionv1 "k8s.io/api/admission/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | ) 15 | 16 | type hiveOwnershipTestSuites struct { 17 | testName string 18 | testID string 19 | username string 20 | userGroups []string 21 | oldObject *runtime.RawExtension 22 | operation admissionv1.Operation 23 | labels map[string]string 24 | shouldBeAllowed bool 25 | } 26 | 27 | const testObjectRaw string = `{ 28 | "metadata": { 29 | "name": "%s", 30 | "uid": "%s", 31 | "creationTimestamp": "2020-05-10T07:51:00Z", 32 | "labels": %s 33 | }, 34 | "users": null 35 | }` 36 | 37 | // labelsMapToString is a helper to turn a map into a JSON fragment to be 38 | // inserted into the testNamespaceRaw const. See createRawJSONString. 39 | func labelsMapToString(labels map[string]string) string { 40 | ret, _ := json.Marshal(labels) 41 | return string(ret) 42 | } 43 | 44 | func createRawJSONString(name, uid string, labels map[string]string) string { 45 | return fmt.Sprintf(testObjectRaw, name, uid, labelsMapToString(labels)) 46 | } 47 | func createOldObject(name, uid string, labels map[string]string) *runtime.RawExtension { 48 | return &runtime.RawExtension{ 49 | Raw: []byte(createRawJSONString(name, uid, labels)), 50 | } 51 | } 52 | 53 | func runTests(t *testing.T, tests []hiveOwnershipTestSuites) { 54 | gvk := metav1.GroupVersionKind{ 55 | Group: "quota.openshift.io", 56 | Version: "v1", 57 | Kind: "ClusterResourceQuota", 58 | } 59 | gvr := metav1.GroupVersionResource{ 60 | Group: "quota.openshift.io", 61 | Version: "v1", 62 | Resource: "clusterresourcequotas", 63 | } 64 | 65 | for _, test := range tests { 66 | obj := createOldObject(test.testName, test.testID, test.labels) 67 | hook := NewWebhook() 68 | httprequest, err := testutils.CreateHTTPRequest(hook.GetURI(), 69 | test.testID, 70 | gvk, gvr, test.operation, test.username, test.userGroups, "", obj, test.oldObject) 71 | if err != nil { 72 | t.Fatalf("Expected no error, got %s", err.Error()) 73 | } 74 | 75 | response, err := testutils.SendHTTPRequest(httprequest, hook) 76 | if err != nil { 77 | t.Fatalf("Expected no error, got %s", err.Error()) 78 | } 79 | if response.UID == "" { 80 | t.Fatalf("No tracking UID associated with the response: %+v", response) 81 | } 82 | 83 | if response.Allowed != test.shouldBeAllowed { 84 | t.Fatalf("Mismatch: %s (groups=%s) %s %s. Test's expectation is that the user %s", 85 | test.username, test.userGroups, 86 | testutils.CanCanNot(response.Allowed), string(test.operation), 87 | testutils.CanCanNot(test.shouldBeAllowed)) 88 | } 89 | } 90 | } 91 | 92 | func TestThing(t *testing.T) { 93 | tests := []hiveOwnershipTestSuites{ 94 | { 95 | testID: "kube-admin-test", 96 | username: "kube:admin", 97 | userGroups: []string{"kube:system", "system:authenticated", "system:authenticated:oauth"}, 98 | operation: admissionv1.Create, 99 | shouldBeAllowed: true, 100 | }, 101 | { 102 | testID: "kube-admin-test", 103 | username: "backplane-cluster-admin", 104 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 105 | operation: admissionv1.Create, 106 | shouldBeAllowed: true, 107 | }, 108 | { 109 | testID: "sre-test", 110 | username: "sre-foo@redhat.com", 111 | userGroups: []string{adminGroups[0], "system:authenticated", "system:authenticated:oauth"}, 112 | operation: admissionv1.Update, 113 | shouldBeAllowed: true, 114 | }, 115 | { 116 | // dedicated-admin users. This should be blocked as making changes as CU on clusterresourcequota which are managed are prohibited. 117 | testID: "dedicated-admin-test", 118 | username: "bob@foo.com", 119 | userGroups: []string{"dedicated-admins", "system:authenticated", "system:authenticated:oauth"}, 120 | operation: admissionv1.Update, 121 | labels: map[string]string{"hive.openshift.io/managed": "true"}, 122 | shouldBeAllowed: false, 123 | }, 124 | { 125 | // no special privileges, only an authenticated user. This should be blocked as making changes on clusterresourcequota which are managed are prohibited. 126 | testID: "unpriv-update-test", 127 | username: "unpriv-user", 128 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 129 | operation: admissionv1.Update, 130 | labels: map[string]string{"hive.openshift.io/managed": "true"}, 131 | shouldBeAllowed: false, 132 | }, 133 | } 134 | runTests(t, tests) 135 | } 136 | 137 | func TestBadRequests(t *testing.T) { 138 | t.Skip() 139 | } 140 | 141 | func TestName(t *testing.T) { 142 | if NewWebhook().Name() == "" { 143 | t.Fatalf("Empty hook name") 144 | } 145 | } 146 | 147 | func TestRules(t *testing.T) { 148 | if len(NewWebhook().Rules()) == 0 { 149 | t.Log("No rules for this webhook?") 150 | } 151 | } 152 | 153 | func TestGetURI(t *testing.T) { 154 | if NewWebhook().GetURI()[0] != '/' { 155 | t.Fatalf("Hook URI does not begin with a /") 156 | } 157 | } 158 | 159 | func TestObjectSelector(t *testing.T) { 160 | obj := &metav1.LabelSelector{ 161 | MatchLabels: map[string]string{ 162 | "hive.openshift.io/managed": "true", 163 | }, 164 | } 165 | 166 | if !reflect.DeepEqual(NewWebhook().ObjectSelector(), obj) { 167 | t.Fatalf("hive managed resources label name is not correct.") 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /pkg/webhooks/imagecontentpolicies/imagecontentpolicies.go: -------------------------------------------------------------------------------- 1 | package imagecontentpolicies 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | 7 | "github.com/go-logr/logr" 8 | configv1 "github.com/openshift/api/config/v1" 9 | operatorv1alpha1 "github.com/openshift/api/operator/v1alpha1" 10 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 11 | admissionregv1 "k8s.io/api/admissionregistration/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | logf "sigs.k8s.io/controller-runtime/pkg/log" 15 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 16 | ) 17 | 18 | const ( 19 | WebhookName = "imagecontentpolicies-validation" 20 | WebhookDoc = "Managed OpenShift customers may not create ImageContentSourcePolicy, ImageDigestMirrorSet, or ImageTagMirrorSet resources that configure mirrors that would conflict with system registries (e.g. quay.io, registry.redhat.io, registry.access.redhat.com, etc). For more details, see https://docs.openshift.com/" 21 | // unauthorizedRepositoryMirrors is a regex that is used to reject certain specified repository mirrors. 22 | // Only registry.redhat.io exactly is blocked, while all other contained regexes 23 | // follow a similar pattern, i.e. rejecting quay.io or quay.io/.* 24 | unauthorizedRepositoryMirrors = `(^registry\.redhat\.io$|^quay\.io(/.*)?$|^registry\.access\.redhat\.com(/.*)?)` 25 | ) 26 | 27 | type ImageContentPoliciesWebhook struct { 28 | scheme *runtime.Scheme 29 | log logr.Logger 30 | } 31 | 32 | func NewWebhook() *ImageContentPoliciesWebhook { 33 | return &ImageContentPoliciesWebhook{ 34 | scheme: runtime.NewScheme(), 35 | log: logf.Log.WithName(WebhookName), 36 | } 37 | } 38 | 39 | func (w *ImageContentPoliciesWebhook) Authorized(request admission.Request) admission.Response { 40 | decoder := admission.NewDecoder(w.scheme) 41 | 42 | switch request.RequestKind.Kind { 43 | case "ImageDigestMirrorSet": 44 | idms := configv1.ImageDigestMirrorSet{} 45 | if err := decoder.Decode(request, &idms); err != nil { 46 | w.log.Error(err, "failed to render an ImageDigestMirrorSet from request") 47 | return admission.Errored(http.StatusBadRequest, err) 48 | } 49 | 50 | if !authorizeImageDigestMirrorSet(idms) { 51 | w.log.Info("denying ImageDigestMirrorSet", "name", idms.Name) 52 | return utils.WebhookResponse(request, false, WebhookDoc) 53 | } 54 | case "ImageTagMirrorSet": 55 | itms := configv1.ImageTagMirrorSet{} 56 | if err := decoder.Decode(request, &itms); err != nil { 57 | w.log.Error(err, "failed to render an ImageTagMirrorSet from request") 58 | return admission.Errored(http.StatusBadRequest, err) 59 | } 60 | 61 | if !authorizeImageTagMirrorSet(itms) { 62 | w.log.Info("denying ImageTagMirrorSet", "name", itms.Name) 63 | return utils.WebhookResponse(request, false, WebhookDoc) 64 | } 65 | case "ImageContentSourcePolicy": 66 | icsp := operatorv1alpha1.ImageContentSourcePolicy{} 67 | if err := decoder.Decode(request, &icsp); err != nil { 68 | w.log.Error(err, "failed to render an ImageContentSourcePolicy from request") 69 | return admission.Errored(http.StatusBadRequest, err) 70 | } 71 | 72 | if !authorizeImageContentSourcePolicy(icsp) { 73 | w.log.Info("denying ImageContentSourcePolicy", "name", icsp.Name) 74 | return utils.WebhookResponse(request, false, WebhookDoc) 75 | } 76 | } 77 | 78 | return utils.WebhookResponse(request, true, "") 79 | } 80 | 81 | func (w *ImageContentPoliciesWebhook) GetURI() string { 82 | return "/" + WebhookName 83 | } 84 | 85 | func (w *ImageContentPoliciesWebhook) Validate(request admission.Request) bool { 86 | if len(request.Object.Raw) == 0 { 87 | // Unexpected, but if the request object is empty we have no hope of decoding it 88 | return false 89 | } 90 | 91 | switch request.Kind.Kind { 92 | case "ImageDigestMirrorSet": 93 | fallthrough 94 | case "ImageTagMirrorSet": 95 | fallthrough 96 | case "ImageContentSourcePolicy": 97 | return true 98 | default: 99 | return false 100 | } 101 | } 102 | 103 | func (w *ImageContentPoliciesWebhook) Name() string { 104 | return WebhookName 105 | } 106 | 107 | func (w *ImageContentPoliciesWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 108 | // Fail-closed because if we allow a problematic ImageContentPolicy/ImageContentSourcePolicy through, 109 | // it will have significant impact on the cluster. We should not modify this to fail-open unless we have 110 | // other specific observability and guidance to detect misconfigured ImageContentPolicy/ImageContentSourcePolicy 111 | // resources. 112 | return admissionregv1.Fail 113 | } 114 | 115 | func (w *ImageContentPoliciesWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 116 | // Equivalent means a request should be intercepted if modifies a resource listed in rules, even via another API group or version. 117 | // Specifying Equivalent is recommended, and ensures that webhooks continue to intercept the resources they expect when upgrades enable new versions of the resource in the API server. 118 | return admissionregv1.Equivalent 119 | } 120 | 121 | func (w *ImageContentPoliciesWebhook) Rules() []admissionregv1.RuleWithOperations { 122 | clusterScope := admissionregv1.ClusterScope 123 | return []admissionregv1.RuleWithOperations{ 124 | { 125 | Operations: []admissionregv1.OperationType{admissionregv1.Create, admissionregv1.Update}, 126 | Rule: admissionregv1.Rule{ 127 | APIGroups: []string{configv1.GroupName}, 128 | APIVersions: []string{"*"}, 129 | Resources: []string{"imagedigestmirrorsets", "imagetagmirrorsets"}, 130 | Scope: &clusterScope, 131 | }, 132 | }, 133 | { 134 | Operations: []admissionregv1.OperationType{admissionregv1.Create, admissionregv1.Update}, 135 | Rule: admissionregv1.Rule{ 136 | APIGroups: []string{operatorv1alpha1.GroupName}, 137 | APIVersions: []string{"*"}, 138 | Resources: []string{"imagecontentsourcepolicies"}, 139 | Scope: &clusterScope, 140 | }, 141 | }, 142 | } 143 | } 144 | 145 | func (w *ImageContentPoliciesWebhook) ObjectSelector() *metav1.LabelSelector { 146 | return nil 147 | } 148 | 149 | func (w *ImageContentPoliciesWebhook) SideEffects() admissionregv1.SideEffectClass { 150 | return admissionregv1.SideEffectClassNone 151 | } 152 | 153 | func (w *ImageContentPoliciesWebhook) TimeoutSeconds() int32 { 154 | return 2 155 | } 156 | 157 | func (w *ImageContentPoliciesWebhook) Doc() string { 158 | return WebhookDoc 159 | } 160 | 161 | func (w *ImageContentPoliciesWebhook) SyncSetLabelSelector() metav1.LabelSelector { 162 | return utils.DefaultLabelSelector() 163 | } 164 | 165 | func (w *ImageContentPoliciesWebhook) ClassicEnabled() bool { 166 | return true 167 | } 168 | 169 | func (w *ImageContentPoliciesWebhook) HypershiftEnabled() bool { 170 | return false 171 | } 172 | 173 | // authorizeImageDigestMirrorSet should reject an ImageDigestMirrorSet that matches an unauthorized mirror list 174 | func authorizeImageDigestMirrorSet(idms configv1.ImageDigestMirrorSet) bool { 175 | unauthorizedRepositoryMirrorsRe := regexp.MustCompile(unauthorizedRepositoryMirrors) 176 | for _, mirror := range idms.Spec.ImageDigestMirrors { 177 | if unauthorizedRepositoryMirrorsRe.Match([]byte(mirror.Source)) { 178 | return false 179 | } 180 | } 181 | 182 | return true 183 | } 184 | 185 | // authorizeImageTagMirrorSet should reject an ImageTagMirrorSet that matches an unauthorized mirror list 186 | func authorizeImageTagMirrorSet(itms configv1.ImageTagMirrorSet) bool { 187 | unauthorizedRepositoryMirrorsRe := regexp.MustCompile(unauthorizedRepositoryMirrors) 188 | for _, mirror := range itms.Spec.ImageTagMirrors { 189 | if unauthorizedRepositoryMirrorsRe.Match([]byte(mirror.Source)) { 190 | return false 191 | } 192 | } 193 | 194 | return true 195 | } 196 | 197 | // authorizeImageContentSourcePolicy should reject an ImageContentSourcePolicy that matches an unauthorized mirror list 198 | func authorizeImageContentSourcePolicy(icsp operatorv1alpha1.ImageContentSourcePolicy) bool { 199 | unauthorizedRepositoryMirrorsRe := regexp.MustCompile(unauthorizedRepositoryMirrors) 200 | for _, mirror := range icsp.Spec.RepositoryDigestMirrors { 201 | if unauthorizedRepositoryMirrorsRe.Match([]byte(mirror.Source)) { 202 | return false 203 | } 204 | } 205 | 206 | return true 207 | } 208 | -------------------------------------------------------------------------------- /pkg/webhooks/ingressconfig/ingressconfig.go: -------------------------------------------------------------------------------- 1 | package ingressconfig 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "sync" 7 | 8 | admissionv1 "k8s.io/api/admission/v1" 9 | admissionregv1 "k8s.io/api/admissionregistration/v1" 10 | corev1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | logf "sigs.k8s.io/controller-runtime/pkg/log" 14 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 15 | 16 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 17 | ) 18 | 19 | const ( 20 | WebhookName string = "ingress-config-validation" 21 | privilegedUsers string = `system:admin` 22 | docString string = `Managed OpenShift customers may not modify ingress config resources because it can can degrade cluster operators and can interfere with OpenShift SRE monitoring.` 23 | ) 24 | 25 | var ( 26 | log = logf.Log.WithName(WebhookName) 27 | privilegedServiceAccountsRe = regexp.MustCompile(utils.PrivilegedServiceAccountGroups) 28 | privilegedUsersRe = regexp.MustCompile(privilegedUsers) 29 | 30 | scope = admissionregv1.ClusterScope 31 | rules = []admissionregv1.RuleWithOperations{ 32 | { 33 | Operations: []admissionregv1.OperationType{"CREATE", "UPDATE", "DELETE"}, 34 | Rule: admissionregv1.Rule{ 35 | APIGroups: []string{"config.openshift.io"}, 36 | APIVersions: []string{"*"}, 37 | Resources: []string{"ingresses"}, 38 | Scope: &scope, 39 | }, 40 | }, 41 | } 42 | ) 43 | 44 | type IngressConfigWebhook struct { 45 | mu sync.Mutex 46 | s runtime.Scheme 47 | } 48 | 49 | // Authorized will determine if the request is allowed 50 | func (w *IngressConfigWebhook) Authorized(request admissionctl.Request) (ret admissionctl.Response) { 51 | ret = admissionctl.Denied("Only privileged service accounts may access") 52 | ret.UID = request.AdmissionRequest.UID 53 | 54 | // allow if modified by an allowlist-ed service account 55 | for _, group := range request.UserInfo.Groups { 56 | if privilegedServiceAccountsRe.Match([]byte(group)) { 57 | ret = admissionctl.Allowed("Privileged service accounts may access") 58 | ret.UID = request.AdmissionRequest.UID 59 | } 60 | } 61 | 62 | // allow if modified by an allowliste-ed user 63 | if privilegedUsersRe.Match([]byte(request.UserInfo.Username)) { 64 | ret = admissionctl.Allowed("Privileged service accounts may access") 65 | ret.UID = request.AdmissionRequest.UID 66 | } 67 | 68 | return 69 | } 70 | 71 | // GetURI returns the URI for the webhook 72 | func (w *IngressConfigWebhook) GetURI() string { return "/ingressconfig-validation" } 73 | 74 | // Validate will validate the incoming request 75 | func (w *IngressConfigWebhook) Validate(req admissionctl.Request) bool { 76 | valid := true 77 | valid = valid && (req.UserInfo.Username != "") 78 | valid = valid && (req.Kind.Kind == "Ingress") 79 | 80 | return valid 81 | } 82 | 83 | // Name is the name of the webhook 84 | func (w *IngressConfigWebhook) Name() string { return WebhookName } 85 | 86 | // FailurePolicy is how the hook config should react if k8s can't access it 87 | func (w *IngressConfigWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 88 | return admissionregv1.Ignore 89 | } 90 | 91 | // MatchPolicy mirrors validatingwebhookconfiguration.webhooks[].matchPolicy 92 | // If it is important to the webhook, be sure to check subResource vs 93 | // requestSubResource. 94 | func (w *IngressConfigWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 95 | return admissionregv1.Equivalent 96 | } 97 | 98 | // Rules is a slice of rules on which this hook should trigger 99 | func (w *IngressConfigWebhook) Rules() []admissionregv1.RuleWithOperations { return rules } 100 | 101 | // ObjectSelector uses a *metav1.LabelSelector to augment the webhook's 102 | // Rules() to match only on incoming requests which match the specific 103 | // LabelSelector. 104 | func (w *IngressConfigWebhook) ObjectSelector() *metav1.LabelSelector { return nil } 105 | 106 | // SideEffects are what side effects, if any, this hook has. Refer to 107 | // https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#side-effects 108 | func (w *IngressConfigWebhook) SideEffects() admissionregv1.SideEffectClass { 109 | return admissionregv1.SideEffectClassNone 110 | } 111 | 112 | // TimeoutSeconds returns an int32 representing how long to wait for this hook to complete 113 | func (w *IngressConfigWebhook) TimeoutSeconds() int32 { return 2 } 114 | 115 | // Doc returns a string for end-customer documentation purposes. 116 | func (w *IngressConfigWebhook) Doc() string { return docString } 117 | 118 | // SyncSetLabelSelector returns the label selector to use in the SyncSet. 119 | // Return utils.DefaultLabelSelector() to stick with the default 120 | func (w *IngressConfigWebhook) SyncSetLabelSelector() metav1.LabelSelector { 121 | return utils.DefaultLabelSelector() 122 | } 123 | 124 | func (w *IngressConfigWebhook) ClassicEnabled() bool { return true } 125 | 126 | // HypershiftEnabled will return boolean value for hypershift enabled configurations 127 | func (w *IngressConfigWebhook) HypershiftEnabled() bool { return true } 128 | 129 | // NewWebhook creates a new webhook 130 | func NewWebhook() *IngressConfigWebhook { 131 | scheme := runtime.NewScheme() 132 | err := admissionv1.AddToScheme(scheme) 133 | if err != nil { 134 | log.Error(err, "Fail adding admissionsv1 scheme to IngressConfigWebhook") 135 | os.Exit(1) 136 | } 137 | 138 | err = corev1.AddToScheme(scheme) 139 | if err != nil { 140 | log.Error(err, "Fail adding corev1 scheme to IngressConfigWebhook") 141 | os.Exit(1) 142 | } 143 | 144 | return &IngressConfigWebhook{ 145 | s: *scheme, 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /pkg/webhooks/ingressconfig/ingressconfig_test.go: -------------------------------------------------------------------------------- 1 | package ingressconfig 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 8 | admissionv1 "k8s.io/api/admission/v1" 9 | admissionregv1 "k8s.io/api/admissionregistration/v1" 10 | authenticationv1 "k8s.io/api/authentication/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 13 | ) 14 | 15 | func TestAuthorized(t *testing.T) { 16 | tests := []struct { 17 | Name string 18 | Request admissionctl.Request 19 | ExpectAllowed bool 20 | }{ 21 | { 22 | Name: "privileged account should be allowed", 23 | Request: admissionctl.Request{ 24 | AdmissionRequest: admissionv1.AdmissionRequest{ 25 | UserInfo: authenticationv1.UserInfo{ 26 | Groups: []string{ 27 | "system:serviceaccounts:openshift-backplane-srep", 28 | }, 29 | }, 30 | }, 31 | }, 32 | ExpectAllowed: true, 33 | }, 34 | { 35 | Name: "system admin should be allowed", 36 | Request: admissionctl.Request{ 37 | AdmissionRequest: admissionv1.AdmissionRequest{ 38 | UserInfo: authenticationv1.UserInfo{ 39 | Username: "system:admin", 40 | }, 41 | }, 42 | }, 43 | ExpectAllowed: true, 44 | }, 45 | { 46 | Name: "non-privileged account should be denied", 47 | Request: admissionctl.Request{ 48 | AdmissionRequest: admissionv1.AdmissionRequest{ 49 | UserInfo: authenticationv1.UserInfo{ 50 | Groups: []string{}, 51 | }, 52 | }, 53 | }, 54 | ExpectAllowed: false, 55 | }, 56 | } 57 | 58 | for _, test := range tests { 59 | t.Run(test.Name, func(t *testing.T) { 60 | w := NewWebhook() 61 | ret := w.Authorized(test.Request) 62 | 63 | if ret.Allowed != test.ExpectAllowed { 64 | t.Errorf("TestAuthorized() %s: request %v - allowed: %t, expected: %t\n", test.Name, test.Request, ret.Allowed, test.ExpectAllowed) 65 | } 66 | }) 67 | } 68 | } 69 | 70 | func TestGetURI(t *testing.T) { 71 | uri := NewWebhook().GetURI() 72 | 73 | if uri != "/ingressconfig-validation" { 74 | t.Errorf("TestGetURI(): expected \"/ingressconfig-validation\", got: %s", uri) 75 | } 76 | } 77 | 78 | func TestValidate(t *testing.T) { 79 | tests := []struct { 80 | Name string 81 | Request admissionctl.Request 82 | ExpectValid bool 83 | }{ 84 | { 85 | Name: "invalidate requests without a username", 86 | Request: admissionctl.Request{ 87 | AdmissionRequest: admissionv1.AdmissionRequest{ 88 | UserInfo: authenticationv1.UserInfo{ 89 | Username: "", 90 | }, 91 | Kind: metav1.GroupVersionKind{ 92 | Kind: "Ingress", 93 | }, 94 | }, 95 | }, 96 | ExpectValid: false, 97 | }, 98 | { 99 | Name: "invalidate requests without a kind", 100 | Request: admissionctl.Request{ 101 | AdmissionRequest: admissionv1.AdmissionRequest{ 102 | UserInfo: authenticationv1.UserInfo{ 103 | Username: "test", 104 | }, 105 | }, 106 | }, 107 | ExpectValid: false, 108 | }, 109 | { 110 | Name: "validate requests", 111 | Request: admissionctl.Request{ 112 | AdmissionRequest: admissionv1.AdmissionRequest{ 113 | UserInfo: authenticationv1.UserInfo{ 114 | Username: "test", 115 | }, 116 | Kind: metav1.GroupVersionKind{ 117 | Kind: "Ingress", 118 | }, 119 | }, 120 | }, 121 | ExpectValid: true, 122 | }, 123 | } 124 | 125 | for _, test := range tests { 126 | t.Run(test.Name, func(t *testing.T) { 127 | w := NewWebhook() 128 | valid := w.Validate(test.Request) 129 | 130 | if valid != test.ExpectValid { 131 | t.Errorf("TestValidate() %s: expected %t, got %t\n", test.Name, test.ExpectValid, valid) 132 | } 133 | }) 134 | } 135 | } 136 | 137 | func TestName(t *testing.T) { 138 | name := NewWebhook().Name() 139 | 140 | if name != "ingress-config-validation" { 141 | t.Errorf("Name(): expected \"ingress-config-validation\", got \"%s\"\n", name) 142 | } 143 | } 144 | 145 | func TestFailurePolicy(t *testing.T) { 146 | policy := NewWebhook().FailurePolicy() 147 | 148 | if policy != admissionregv1.Ignore { 149 | t.Errorf("TestFailurePolicy(): expected Ignore, got %s\n", policy) 150 | } 151 | } 152 | 153 | func TestMatchPolicy(t *testing.T) { 154 | policy := NewWebhook().MatchPolicy() 155 | 156 | if policy != admissionregv1.Equivalent { 157 | t.Errorf("TestFailurePolicy(): expected Equivalent, got %s\n", policy) 158 | } 159 | } 160 | 161 | func TestRules(t *testing.T) { 162 | scope := admissionregv1.ClusterScope 163 | expectedRules := []admissionregv1.RuleWithOperations{ 164 | { 165 | Operations: []admissionregv1.OperationType{"CREATE", "UPDATE", "DELETE"}, 166 | Rule: admissionregv1.Rule{ 167 | APIGroups: []string{"config.openshift.io"}, 168 | APIVersions: []string{"*"}, 169 | Resources: []string{"ingresses"}, 170 | Scope: &scope, 171 | }, 172 | }, 173 | } 174 | 175 | rules := NewWebhook().Rules() 176 | 177 | if !reflect.DeepEqual(expectedRules, rules) { 178 | t.Errorf("TestRules(): expected %v, got %v\n", expectedRules, rules) 179 | } 180 | } 181 | 182 | func TestObjectSelector(t *testing.T) { 183 | labelSelector := NewWebhook().ObjectSelector() 184 | 185 | if labelSelector != nil { 186 | t.Errorf("TestObjectSelector(): expected nil, got %v\n", labelSelector) 187 | } 188 | } 189 | 190 | func TestSideEffects(t *testing.T) { 191 | sideEffects := NewWebhook().SideEffects() 192 | 193 | if sideEffects != admissionregv1.SideEffectClassNone { 194 | t.Errorf("TestSideEffects(): expected %v, got %v\n", admissionregv1.SideEffectClassNone, sideEffects) 195 | } 196 | } 197 | 198 | func TestTimeoutSeconds(t *testing.T) { 199 | timeout := NewWebhook().TimeoutSeconds() 200 | 201 | if timeout != 2 { 202 | t.Errorf("TestTimeoutSeconds(): expected 2, got %d\n", timeout) 203 | } 204 | } 205 | 206 | func TestDoc(t *testing.T) { 207 | docs := NewWebhook().Doc() 208 | 209 | if len(docs) == 0 { 210 | t.Error("TestDoc(): expected content, recieved none") 211 | } 212 | } 213 | 214 | func TestSyncSetLabelSelector(t *testing.T) { 215 | labelSelector := NewWebhook().SyncSetLabelSelector() 216 | 217 | if !reflect.DeepEqual(labelSelector, utils.DefaultLabelSelector()) { 218 | t.Errorf("TestSyncSetLabelSelector(): expected %v, got %v\n", utils.DefaultLabelSelector(), labelSelector) 219 | } 220 | } 221 | 222 | func TestHypershiftEnabled(t *testing.T) { 223 | enabled := NewWebhook().HypershiftEnabled() 224 | 225 | if !enabled { 226 | t.Error("TestHypershiftEnabled(): expected enabled") 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /pkg/webhooks/ingresscontroller/ingresscontroller.go: -------------------------------------------------------------------------------- 1 | package ingresscontroller 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "slices" 7 | "strings" 8 | 9 | operatorv1 "github.com/openshift/api/operator/v1" 10 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 11 | admissionregv1 "k8s.io/api/admissionregistration/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 15 | 16 | logf "sigs.k8s.io/controller-runtime/pkg/log" 17 | ) 18 | 19 | const ( 20 | WebhookName string = "ingresscontroller-validation" 21 | docString string = `Managed OpenShift Customer may create IngressControllers without necessary taints. This can cause those workloads to be provisioned on master nodes.` 22 | legacyIngressSupportFeatureFlag = "ext-managed.openshift.io/legacy-ingress-support" 23 | ) 24 | 25 | var ( 26 | log = logf.Log.WithName(WebhookName) 27 | scope = admissionregv1.NamespacedScope 28 | rules = []admissionregv1.RuleWithOperations{ 29 | { 30 | Operations: []admissionregv1.OperationType{admissionregv1.Create, admissionregv1.Update}, 31 | Rule: admissionregv1.Rule{ 32 | APIGroups: []string{"operator.openshift.io"}, 33 | APIVersions: []string{"*"}, 34 | Resources: []string{"ingresscontroller", "ingresscontrollers"}, 35 | Scope: &scope, 36 | }, 37 | }, 38 | } 39 | allowedUsers = []string{ 40 | "backplane-cluster-admin", 41 | } 42 | ) 43 | 44 | type IngressControllerWebhook struct { 45 | s runtime.Scheme 46 | } 47 | 48 | // ObjectSelector implements Webhook interface 49 | func (wh *IngressControllerWebhook) ObjectSelector() *metav1.LabelSelector { return nil } 50 | 51 | func (wh *IngressControllerWebhook) Doc() string { 52 | return fmt.Sprintf(docString) 53 | } 54 | 55 | // TimeoutSeconds implements Webhook interface 56 | func (wh *IngressControllerWebhook) TimeoutSeconds() int32 { return 1 } 57 | 58 | // MatchPolicy implements Webhook interface 59 | func (wh *IngressControllerWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 60 | return admissionregv1.Equivalent 61 | } 62 | 63 | // Name implements Webhook interface 64 | func (wh *IngressControllerWebhook) Name() string { return WebhookName } 65 | 66 | // FailurePolicy implements Webhook interface and defines how unrecognized errors and timeout errors from the admission webhook are handled. Allowed values are Ignore or Fail. 67 | // Ignore means that an error calling the webhook is ignored and the API request is allowed to continue. 68 | // It's important to leave the FailurePolicy set to Ignore because otherwise the pod will fail to be created as the API request will be rejected. 69 | func (wh *IngressControllerWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 70 | return admissionregv1.Ignore 71 | } 72 | 73 | // Rules implements Webhook interface 74 | func (wh *IngressControllerWebhook) Rules() []admissionregv1.RuleWithOperations { return rules } 75 | 76 | // GetURI implements Webhook interface 77 | func (wh *IngressControllerWebhook) GetURI() string { return "/" + WebhookName } 78 | 79 | // SideEffects implements Webhook interface 80 | func (wh *IngressControllerWebhook) SideEffects() admissionregv1.SideEffectClass { 81 | return admissionregv1.SideEffectClassNone 82 | } 83 | 84 | // Validate implements Webhook interface 85 | func (wh *IngressControllerWebhook) Validate(req admissionctl.Request) bool { 86 | valid := true 87 | valid = valid && (req.UserInfo.Username != "") 88 | valid = valid && (req.Kind.Kind == "IngressController") 89 | 90 | return valid 91 | } 92 | 93 | func (wh *IngressControllerWebhook) renderIngressController(req admissionctl.Request) (*operatorv1.IngressController, error) { 94 | decoder := admissionctl.NewDecoder(&wh.s) 95 | ic := &operatorv1.IngressController{} 96 | err := decoder.DecodeRaw(req.Object, ic) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return ic, nil 102 | } 103 | 104 | func (wh *IngressControllerWebhook) authorized(request admissionctl.Request) admissionctl.Response { 105 | var ret admissionctl.Response 106 | ic, err := wh.renderIngressController(request) 107 | if err != nil { 108 | log.Error(err, "Couldn't render an IngressController from the incoming request") 109 | return admissionctl.Errored(http.StatusBadRequest, err) 110 | } 111 | 112 | log.Info("Checking if user is unauthenticated") 113 | if request.AdmissionRequest.UserInfo.Username == "system:unauthenticated" { 114 | // This could highlight a significant problem with RBAC since an 115 | // unauthenticated user should have no permissions. 116 | log.Info("system:unauthenticated made a webhook request. Check RBAC rules", "request", request.AdmissionRequest) 117 | ret = admissionctl.Denied("Unauthenticated") 118 | ret.UID = request.AdmissionRequest.UID 119 | return ret 120 | } 121 | 122 | log.Info("Checking if user is authenticated system: user") 123 | if strings.HasPrefix(request.AdmissionRequest.UserInfo.Username, "system:") { 124 | ret = admissionctl.Allowed("authenticated system: users are allowed") 125 | ret.UID = request.AdmissionRequest.UID 126 | return ret 127 | } 128 | 129 | log.Info("Checking if user is kube: user") 130 | if strings.HasPrefix(request.AdmissionRequest.UserInfo.Username, "kube:") { 131 | ret = admissionctl.Allowed("kube: users are allowed") 132 | ret.UID = request.AdmissionRequest.UID 133 | return ret 134 | } 135 | 136 | // Check if the group does not have exceptions 137 | if !isAllowedUser(request) { 138 | for _, toleration := range ic.Spec.NodePlacement.Tolerations { 139 | if strings.Contains(toleration.Key, "node-role.kubernetes.io/master") { 140 | ret = admissionctl.Denied("Not allowed to provision ingress controller pods with toleration for master nodes.") 141 | ret.UID = request.AdmissionRequest.UID 142 | 143 | return ret 144 | } 145 | } 146 | } 147 | 148 | ret = admissionctl.Allowed("IngressController operation is allowed") 149 | ret.UID = request.AdmissionRequest.UID 150 | 151 | return ret 152 | } 153 | 154 | // isAllowedUser checks if the user is allowed to perform the action 155 | func isAllowedUser(request admissionctl.Request) bool { 156 | log.Info(fmt.Sprintf("Checking username %s on whitelist", request.UserInfo.Username)) 157 | if slices.Contains(allowedUsers, request.UserInfo.Username) { 158 | log.Info(fmt.Sprintf("%s is listed in whitelist", request.UserInfo.Username)) 159 | return true 160 | } 161 | 162 | log.Info("No allowed user found") 163 | 164 | return false 165 | } 166 | 167 | // Authorized implements Webhook interface 168 | func (wh *IngressControllerWebhook) Authorized(request admissionctl.Request) admissionctl.Response { 169 | return wh.authorized(request) 170 | } 171 | 172 | // SyncSetLabelSelector returns the label selector to use in the SyncSet. 173 | // We turn on 'managed ingress v2' by setting legacy ingress to 'false' 174 | // See https://github.com/openshift/cloud-ingress-operator/blob/master/hack/olm-registry/olm-artifacts-template.yaml 175 | // and 176 | // https://github.com/openshift/custom-domains-operator/blob/master/hack/olm-registry/olm-artifacts-template.yaml 177 | // For examples of use. 178 | func (s *IngressControllerWebhook) SyncSetLabelSelector() metav1.LabelSelector { 179 | customLabelSelector := utils.DefaultLabelSelector() 180 | customLabelSelector.MatchExpressions = append(customLabelSelector.MatchExpressions, 181 | metav1.LabelSelectorRequirement{ 182 | Key: legacyIngressSupportFeatureFlag, 183 | Operator: metav1.LabelSelectorOpIn, 184 | Values: []string{ 185 | "false", 186 | }, 187 | }) 188 | return customLabelSelector 189 | } 190 | 191 | func (s *IngressControllerWebhook) ClassicEnabled() bool { return true } 192 | 193 | func (s *IngressControllerWebhook) HypershiftEnabled() bool { return false } 194 | 195 | // NewWebhook creates a new webhook 196 | func NewWebhook() *IngressControllerWebhook { 197 | scheme := runtime.NewScheme() 198 | return &IngressControllerWebhook{ 199 | s: *scheme, 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /pkg/webhooks/networkpolicies/networkpolicies.go: -------------------------------------------------------------------------------- 1 | package networkpolicies 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "regexp" 7 | "slices" 8 | "strings" 9 | 10 | hookconfig "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/config" 11 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 12 | 13 | admissionregv1 "k8s.io/api/admissionregistration/v1" 14 | networkingv1 "k8s.io/api/networking/v1" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/apimachinery/pkg/runtime" 17 | logf "sigs.k8s.io/controller-runtime/pkg/log" 18 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 19 | ) 20 | 21 | const ( 22 | WebhookName string = "networkpolicies-validation" 23 | docString string = `Managed OpenShift Customers may not create NetworkPolicies in namespaces managed by Red Hat.` 24 | ) 25 | 26 | var ( 27 | timeout int32 = 2 28 | allowedUsers = []string{"system:admin", "backplane-cluster-admin"} 29 | sreAdminGroups = []string{"system:serviceaccounts:openshift-backplane-srep"} 30 | privilegedServiceAccountGroupsRe = regexp.MustCompile(utils.PrivilegedServiceAccountGroups) 31 | scope = admissionregv1.NamespacedScope 32 | rules = []admissionregv1.RuleWithOperations{ 33 | { 34 | Operations: []admissionregv1.OperationType{ 35 | admissionregv1.Create, 36 | admissionregv1.Update, 37 | admissionregv1.Delete, 38 | }, 39 | Rule: admissionregv1.Rule{ 40 | APIGroups: []string{"networking.k8s.io"}, 41 | APIVersions: []string{"*"}, 42 | Resources: []string{"networkpolicies"}, 43 | Scope: &scope, 44 | }, 45 | }, 46 | } 47 | log = logf.Log.WithName(WebhookName) 48 | ) 49 | 50 | // networkpoliciesruleWebhook validates a networkpolicy change 51 | type networkpoliciesruleWebhook struct { 52 | s runtime.Scheme 53 | } 54 | 55 | // NewWebhook creates the new webhook 56 | func NewWebhook() *networkpoliciesruleWebhook { 57 | scheme := runtime.NewScheme() 58 | return &networkpoliciesruleWebhook{ 59 | s: *scheme, 60 | } 61 | } 62 | 63 | // Authorized implements Webhook interface 64 | func (s *networkpoliciesruleWebhook) Authorized(request admissionctl.Request) admissionctl.Response { 65 | //Implement authorized next 66 | return s.authorized(request) 67 | } 68 | 69 | func (s *networkpoliciesruleWebhook) authorized(request admissionctl.Request) admissionctl.Response { 70 | var ret admissionctl.Response 71 | 72 | np, err := s.renderNetworkPolicy(request) 73 | if err != nil { 74 | log.Error(err, "Could not render a NetworkPolicy from the incoming request") 75 | return admissionctl.Errored(http.StatusBadRequest, err) 76 | } 77 | 78 | if !isAllowedNamespace(np.GetNamespace()) { 79 | log.Info(fmt.Sprintf("%s operation detected on managed namespace: %s", request.Operation, np.GetNamespace())) 80 | if isAllowedUser(request) { 81 | ret = admissionctl.Allowed(fmt.Sprintf("User '%s' in group(s) '%s' can operate on NetworkPolicies", request.UserInfo.Username, strings.Join(request.UserInfo.Groups, ", "))) 82 | ret.UID = request.AdmissionRequest.UID 83 | return ret 84 | } 85 | for _, group := range request.UserInfo.Groups { 86 | if privilegedServiceAccountGroupsRe.Match([]byte(group)) { 87 | ret = admissionctl.Allowed(fmt.Sprintf("Privileged service accounts in group(s) '%s' can operate on NetworkPolicies", strings.Join(request.UserInfo.Groups, ", "))) 88 | ret.UID = request.AdmissionRequest.UID 89 | return ret 90 | } 91 | } 92 | 93 | ret = admissionctl.Denied(fmt.Sprintf("User '%s' prevented from accessing Red Mat managed resources. This is in an effort to prevent harmful actions that may cause unintended consequences or affect the stability of the cluster. If you have any questions about this, please reach out to Red Hat support at https://access.redhat.com/support", request.UserInfo.Username)) 94 | ret.UID = request.AdmissionRequest.UID 95 | return ret 96 | } 97 | 98 | if np.GetNamespace() == "openshift-ingress" { 99 | ingressName, labelFound := np.Spec.PodSelector.MatchLabels["ingresscontroller.operator.openshift.io/deployment-ingresscontroller"] 100 | if !labelFound || ingressName == "default" { 101 | ret = admissionctl.Denied(fmt.Sprintf("User '%s' prevented from creating network policy that may impact default ingress, which is managed by Red Hat. This is in an effort to prevent harmful actions that may cause unintended consequences or affect the stability of the cluster. If you have any questions about this, please reach out to Red Hat support at https://access.redhat.com/support", request.UserInfo.Username)) 102 | ret.UID = request.AdmissionRequest.UID 103 | return ret 104 | } 105 | } 106 | 107 | log.Info("Allowing access", "request", request.AdmissionRequest) 108 | ret = admissionctl.Allowed("Non managed namespace") 109 | ret.UID = request.AdmissionRequest.UID 110 | return ret 111 | } 112 | 113 | // isAllowedNamespace checks if the namespace is excluded from this webhook 114 | func isAllowedNamespace(namespace string) bool { 115 | return !hookconfig.IsPrivilegedNamespace(namespace) || namespace == "openshift-ingress" 116 | } 117 | 118 | // isAllowedUser checks if the user or group is allowed to perform the action 119 | func isAllowedUser(request admissionctl.Request) bool { 120 | if slices.Contains(allowedUsers, request.UserInfo.Username) { 121 | return true 122 | } 123 | 124 | for _, group := range sreAdminGroups { 125 | if slices.Contains(request.UserInfo.Groups, group) { 126 | return true 127 | } 128 | } 129 | 130 | return false 131 | } 132 | 133 | func (s *networkpoliciesruleWebhook) renderNetworkPolicy(req admissionctl.Request) (*networkingv1.NetworkPolicy, error) { 134 | decoder := admissionctl.NewDecoder(&s.s) 135 | networkPolicy := &networkingv1.NetworkPolicy{} 136 | 137 | if len(req.Object.Raw) > 0 { 138 | err := decoder.DecodeRaw(req.Object, networkPolicy) 139 | return networkPolicy, err 140 | } 141 | err := decoder.DecodeRaw(req.OldObject, networkPolicy) 142 | return networkPolicy, err 143 | } 144 | 145 | // GetURI implements Webhook interface 146 | func (s *networkpoliciesruleWebhook) GetURI() string { 147 | return "/" + WebhookName 148 | } 149 | 150 | // Validate implements Webhook interface 151 | func (s *networkpoliciesruleWebhook) Validate(request admissionctl.Request) bool { 152 | valid := true 153 | valid = valid && (request.UserInfo.Username != "") 154 | valid = valid && (request.Kind.Kind == "NetworkPolicy") 155 | 156 | return valid 157 | } 158 | 159 | // Name implements Webhook interface 160 | func (s *networkpoliciesruleWebhook) Name() string { 161 | return WebhookName 162 | } 163 | 164 | // FailurePolicy implements Webhook interface 165 | func (s *networkpoliciesruleWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 166 | return admissionregv1.Ignore 167 | } 168 | 169 | // MatchPolicy implements Webhook interface 170 | func (s *networkpoliciesruleWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 171 | return admissionregv1.Equivalent 172 | } 173 | 174 | // Rules implements Webhook interface 175 | func (s *networkpoliciesruleWebhook) Rules() []admissionregv1.RuleWithOperations { 176 | return rules 177 | } 178 | 179 | // ObjectSelector implements Webhook interface 180 | func (s *networkpoliciesruleWebhook) ObjectSelector() *metav1.LabelSelector { 181 | return nil 182 | } 183 | 184 | // SideEffects implements Webhook interface 185 | func (s *networkpoliciesruleWebhook) SideEffects() admissionregv1.SideEffectClass { 186 | return admissionregv1.SideEffectClassNone 187 | } 188 | 189 | // TimeoutSeconds implements Webhook interface 190 | func (s *networkpoliciesruleWebhook) TimeoutSeconds() int32 { 191 | return timeout 192 | } 193 | 194 | // Doc implements Webhook interface 195 | func (s *networkpoliciesruleWebhook) Doc() string { 196 | return (docString) 197 | } 198 | 199 | // SyncSetLabelSelector returns the label selector to use in the SyncSet. 200 | // Return utils.DefaultLabelSelector() to stick with the default 201 | func (s *networkpoliciesruleWebhook) SyncSetLabelSelector() metav1.LabelSelector { 202 | return utils.DefaultLabelSelector() 203 | } 204 | 205 | func (s *networkpoliciesruleWebhook) ClassicEnabled() bool { return true } 206 | 207 | func (s *networkpoliciesruleWebhook) HypershiftEnabled() bool { return false } 208 | -------------------------------------------------------------------------------- /pkg/webhooks/pod/pod.go: -------------------------------------------------------------------------------- 1 | package pod 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "regexp" 8 | "sync" 9 | 10 | admissionv1 "k8s.io/api/admission/v1" 11 | admissionregv1 "k8s.io/api/admissionregistration/v1" 12 | corev1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/runtime" 15 | logf "sigs.k8s.io/controller-runtime/pkg/log" 16 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 17 | 18 | hookconfig "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/config" 19 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 20 | ) 21 | 22 | const ( 23 | WebhookName string = "pod-validation" 24 | unprivilegedNamespace string = `(openshift-logging|openshift-operators)` 25 | docString string = `Managed OpenShift Customers may use tolerations on Pods that could cause those Pods to be scheduled on infra or master nodes.` 26 | ) 27 | 28 | var ( 29 | unprivilegedNamespaceRe = regexp.MustCompile(unprivilegedNamespace) 30 | log = logf.Log.WithName(WebhookName) 31 | 32 | scope = admissionregv1.NamespacedScope 33 | rules = []admissionregv1.RuleWithOperations{ 34 | { 35 | Operations: []admissionregv1.OperationType{admissionregv1.OperationAll}, 36 | Rule: admissionregv1.Rule{ 37 | APIGroups: []string{"v1"}, 38 | APIVersions: []string{"*"}, 39 | Resources: []string{"pods"}, 40 | Scope: &scope, 41 | }, 42 | }, 43 | } 44 | ) 45 | 46 | type PodWebhook struct { 47 | mu sync.Mutex 48 | s runtime.Scheme 49 | } 50 | 51 | // ObjectSelector implements Webhook interface 52 | func (s *PodWebhook) ObjectSelector() *metav1.LabelSelector { return nil } 53 | 54 | func (s *PodWebhook) Doc() string { 55 | return fmt.Sprintf(docString) 56 | } 57 | 58 | // TimeoutSeconds implements Webhook interface 59 | func (s *PodWebhook) TimeoutSeconds() int32 { return 1 } 60 | 61 | // MatchPolicy implements Webhook interface 62 | func (s *PodWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 63 | return admissionregv1.Equivalent 64 | } 65 | 66 | // Name implements Webhook interface 67 | func (s *PodWebhook) Name() string { return WebhookName } 68 | 69 | // FailurePolicy implements Webhook interface and defines how unrecognized errors and timeout errors from the admission webhook are handled. Allowed values are Ignore or Fail. 70 | // Ignore means that an error calling the webhook is ignored and the API request is allowed to continue. 71 | // It's important to leave the FailurePolicy set to Ignore because otherwise the pod will fail to be created as the API request will be rejected. 72 | func (s *PodWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 73 | return admissionregv1.Ignore 74 | } 75 | 76 | // Rules implements Webhook interface 77 | func (s *PodWebhook) Rules() []admissionregv1.RuleWithOperations { return rules } 78 | 79 | // GetURI implements Webhook interface 80 | func (s *PodWebhook) GetURI() string { return "/" + WebhookName } 81 | 82 | // SideEffects implements Webhook interface 83 | func (s *PodWebhook) SideEffects() admissionregv1.SideEffectClass { 84 | return admissionregv1.SideEffectClassNone 85 | } 86 | 87 | // Validate implements Webhook interface 88 | func (s *PodWebhook) Validate(req admissionctl.Request) bool { 89 | valid := true 90 | valid = valid && (req.UserInfo.Username != "") 91 | valid = valid && (req.Kind.Kind == "Pod") 92 | 93 | return valid 94 | } 95 | 96 | func (s *PodWebhook) renderPod(req admissionctl.Request) (*corev1.Pod, error) { 97 | decoder := admissionctl.NewDecoder(&s.s) 98 | pod := &corev1.Pod{} 99 | var err error 100 | if len(req.OldObject.Raw) > 0 { 101 | err = decoder.DecodeRaw(req.OldObject, pod) 102 | } else { 103 | err = decoder.DecodeRaw(req.Object, pod) 104 | } 105 | if err != nil { 106 | return nil, err 107 | } 108 | return pod, nil 109 | } 110 | 111 | func isRequestPrivileged(namespace string) bool { 112 | if hookconfig.IsPrivilegedNamespace(namespace) { 113 | if unprivilegedNamespaceRe.Match([]byte(namespace)) { 114 | return false 115 | } 116 | return true 117 | } 118 | return false 119 | } 120 | 121 | // Authorized implements Webhook interface 122 | func (s *PodWebhook) Authorized(request admissionctl.Request) admissionctl.Response { 123 | return s.authorized(request) 124 | } 125 | 126 | func (s *PodWebhook) authorized(request admissionctl.Request) admissionctl.Response { 127 | var ret admissionctl.Response 128 | pod, err := s.renderPod(request) 129 | if err != nil { 130 | log.Error(err, "Couldn't render a Pod from the incoming request") 131 | return admissionctl.Errored(http.StatusBadRequest, err) 132 | } 133 | 134 | // If the incoming Pod is aimed at a privileged namespace except for unprivilegedNamespace, allow it to do whatever it wants. 135 | // However, if the pod is targeting a customer's namespace (aka non-privileged), then it may not tolerate certain master/infra node taints. 136 | if !isRequestPrivileged(pod.ObjectMeta.GetNamespace()) { 137 | for _, toleration := range pod.Spec.Tolerations { 138 | if toleration.Key == "node-role.kubernetes.io/infra" && toleration.Effect == corev1.TaintEffectNoSchedule { 139 | ret = admissionctl.Denied("Not allowed to schedule a pod with NoSchedule taint on infra node") 140 | ret.UID = request.AdmissionRequest.UID 141 | return ret 142 | } 143 | if toleration.Key == "node-role.kubernetes.io/infra" && toleration.Effect == corev1.TaintEffectPreferNoSchedule { 144 | ret = admissionctl.Denied("Not allowed to schedule a pod with PreferNoSchedule taint on infra node") 145 | ret.UID = request.AdmissionRequest.UID 146 | return ret 147 | } 148 | if toleration.Key == "node-role.kubernetes.io/master" && toleration.Effect == corev1.TaintEffectNoSchedule { 149 | ret = admissionctl.Denied("Not allowed to schedule a pod with NoSchedule taint on master node") 150 | ret.UID = request.AdmissionRequest.UID 151 | return ret 152 | } 153 | if toleration.Key == "node-role.kubernetes.io/master" && toleration.Effect == corev1.TaintEffectPreferNoSchedule { 154 | ret = admissionctl.Denied("Not allowed to schedule a pod with PreferNoSchedule taint on master node") 155 | ret.UID = request.AdmissionRequest.UID 156 | return ret 157 | } 158 | } 159 | } 160 | 161 | // Hereafter, all requests are controlled by RBAC 162 | ret = admissionctl.Allowed("Allowed to create Pod because of RBAC") 163 | ret.UID = request.AdmissionRequest.UID 164 | return ret 165 | } 166 | 167 | // SyncSetLabelSelector returns the label selector to use in the SyncSet. 168 | func (s *PodWebhook) SyncSetLabelSelector() metav1.LabelSelector { 169 | return utils.DefaultLabelSelector() 170 | } 171 | 172 | func (s *PodWebhook) ClassicEnabled() bool { return true } 173 | 174 | func (s *PodWebhook) HypershiftEnabled() bool { return false } 175 | 176 | // NewWebhook creates a new webhook 177 | func NewWebhook() *PodWebhook { 178 | scheme := runtime.NewScheme() 179 | err := admissionv1.AddToScheme(scheme) 180 | if err != nil { 181 | log.Error(err, "Fail adding admissionsv1 scheme to PodWebhook") 182 | os.Exit(1) 183 | } 184 | 185 | err = corev1.AddToScheme(scheme) 186 | if err != nil { 187 | log.Error(err, "Fail adding corev1 scheme to PodWebhook") 188 | os.Exit(1) 189 | } 190 | 191 | return &PodWebhook{ 192 | s: *scheme, 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /pkg/webhooks/podimagespec/podimagespec_test.go: -------------------------------------------------------------------------------- 1 | package podimagespec 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | registryv1 "github.com/openshift/api/imageregistry/v1" 9 | corev1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/runtime" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 14 | ) 15 | 16 | type outputImageSpecRegex struct { 17 | matched bool 18 | namespace string 19 | image string 20 | tag string 21 | } 22 | 23 | func newMockRegistry(obs ...client.Object) (client.Client, error) { 24 | s := runtime.NewScheme() 25 | if err := registryv1.Install(s); err != nil { 26 | return nil, err 27 | } 28 | 29 | return fake.NewClientBuilder().WithScheme(s).WithObjects(obs...).Build(), nil 30 | } 31 | 32 | func TestCheckImageRegistryStatus(t *testing.T) { 33 | tests := []struct { 34 | name string 35 | config *registryv1.Config 36 | expected bool 37 | }{ 38 | { 39 | name: "test", 40 | config: ®istryv1.Config{ 41 | ObjectMeta: metav1.ObjectMeta{ 42 | Name: "notfound", 43 | }, 44 | Spec: registryv1.ImageRegistrySpec{}, 45 | Status: registryv1.ImageRegistryStatus{}, 46 | }, 47 | expected: false, 48 | }, 49 | } 50 | 51 | for _, test := range tests { 52 | s := NewWebhook() 53 | s.kubeClient, _ = newMockRegistry(test.config) 54 | actual, _ := s.checkImageRegistryStatus(context.Background()) 55 | if actual != test.expected { 56 | t.Error("failed") 57 | } 58 | } 59 | 60 | } 61 | 62 | func TestCheckContainerImageSpecByRegex(t *testing.T) { 63 | 64 | tests := []struct { 65 | name string 66 | imagespec string 67 | expected outputImageSpecRegex 68 | }{ 69 | { 70 | name: "test uninteresting short imagespec", 71 | imagespec: "ubuntu", 72 | expected: outputImageSpecRegex{ 73 | matched: false, 74 | namespace: "", 75 | image: "", 76 | tag: "", 77 | }, 78 | }, 79 | { 80 | name: "test uninteresting short tagged imagespec", 81 | imagespec: "ubuntu:latest", 82 | expected: outputImageSpecRegex{ 83 | matched: false, 84 | namespace: "", 85 | image: "", 86 | tag: "", 87 | }, 88 | }, 89 | { 90 | name: "test uninteresting fully qualified tagged imagespec", 91 | imagespec: "docker.io/library/ubuntu:latest", 92 | expected: outputImageSpecRegex{ 93 | matched: false, 94 | namespace: "", 95 | image: "", 96 | tag: "", 97 | }, 98 | }, 99 | { 100 | name: "test uninteresting fully qualified SHA imagespec", 101 | imagespec: "quay.io/openshift-release-dev/ocp-release@sha256:4dbe2a75a516a947eab036ef6a1d086f1b1610f6bd21c6ab5f95db68ec177ea2", 102 | expected: outputImageSpecRegex{ 103 | matched: false, 104 | namespace: "", 105 | image: "", 106 | tag: "", 107 | }, 108 | }, 109 | /// TODO: Need to add functionality to replace this with a fully qualified quay.io image. 110 | { 111 | name: "test interesting fully qualified SHA imagespec", 112 | imagespec: "image-registry.openshift-image-registry.svc:5000/openshift/cli@sha256:4dbe2a75a516a947eab036ef6a1d086f1b1610f6bd21c6ab5f95db68ec177ea2", 113 | expected: outputImageSpecRegex{ 114 | matched: false, 115 | namespace: "", 116 | image: "", 117 | tag: "", 118 | }, 119 | }, 120 | { 121 | name: "test interesting fully qualified tagged imagespec", 122 | imagespec: "image-registry.openshift-image-registry.svc:5000/openshift/cli:latest", 123 | expected: outputImageSpecRegex{ 124 | matched: true, 125 | namespace: "openshift", 126 | image: "cli", 127 | tag: "latest", 128 | }, 129 | }, 130 | } 131 | 132 | for _, test := range tests { 133 | actual := outputImageSpecRegex{} 134 | actual.matched, actual.namespace, actual.image, actual.tag = checkContainerImageSpecByRegex(test.imagespec) 135 | if !reflect.DeepEqual(actual, test.expected) { 136 | t.Errorf("TestCheckContainerImageSpecByRegex() %s -\n imagespec: %s \n actual: %v\n expected: %v\n", test.name, test.imagespec, actual, test.expected) 137 | } 138 | } 139 | 140 | } 141 | 142 | func TestPodContainsContainerRegexMatch(t *testing.T) { 143 | tests := []struct { 144 | name string 145 | pod *corev1.Pod 146 | expected bool 147 | }{ 148 | { 149 | name: "test empty pod", 150 | pod: &corev1.Pod{}, 151 | expected: false, 152 | }, 153 | { 154 | name: "test pod with cli image in containers", 155 | pod: &corev1.Pod{ 156 | ObjectMeta: metav1.ObjectMeta{ 157 | Name: "test", 158 | }, 159 | Spec: corev1.PodSpec{ 160 | Containers: []corev1.Container{ 161 | {Image: "image-registry.openshift-image-registry.svc:5000/openshift/cli:latest"}, 162 | }, 163 | }, 164 | Status: corev1.PodStatus{}, 165 | }, 166 | expected: true, 167 | }, 168 | { 169 | name: "test pod with cli image in initcontainers", 170 | pod: &corev1.Pod{ 171 | ObjectMeta: metav1.ObjectMeta{ 172 | Name: "test", 173 | }, 174 | Spec: corev1.PodSpec{ 175 | InitContainers: []corev1.Container{ 176 | {Image: "image-registry.openshift-image-registry.svc:5000/openshift/cli:latest"}, 177 | }, 178 | }, 179 | Status: corev1.PodStatus{}, 180 | }, 181 | expected: true, 182 | }, 183 | { 184 | name: "test pod with uninteresting image in containers", 185 | pod: &corev1.Pod{ 186 | ObjectMeta: metav1.ObjectMeta{ 187 | Name: "test", 188 | }, 189 | Spec: corev1.PodSpec{ 190 | Containers: []corev1.Container{ 191 | {Image: "ubuntu"}, 192 | }, 193 | }, 194 | Status: corev1.PodStatus{}, 195 | }, 196 | expected: false, 197 | }, 198 | { 199 | name: "test pod with uninteresting image in initcontainers", 200 | pod: &corev1.Pod{ 201 | ObjectMeta: metav1.ObjectMeta{ 202 | Name: "test", 203 | }, 204 | Spec: corev1.PodSpec{ 205 | InitContainers: []corev1.Container{ 206 | {Image: "ubuntu"}, 207 | }, 208 | }, 209 | Status: corev1.PodStatus{}, 210 | }, 211 | expected: false, 212 | }, 213 | { 214 | name: "test pod with cli image in initcontainers and uninteresting in containers", 215 | pod: &corev1.Pod{ 216 | ObjectMeta: metav1.ObjectMeta{ 217 | Name: "test", 218 | }, 219 | Spec: corev1.PodSpec{ 220 | Containers: []corev1.Container{ 221 | {Image: "ubuntu"}, 222 | }, 223 | InitContainers: []corev1.Container{ 224 | {Image: "image-registry.openshift-image-registry.svc:5000/openshift/cli:latest"}, 225 | }, 226 | }, 227 | Status: corev1.PodStatus{}, 228 | }, 229 | expected: true, 230 | }, 231 | { 232 | name: "test pod with cli image in containers and uninteresting in initcontainers", 233 | pod: &corev1.Pod{ 234 | ObjectMeta: metav1.ObjectMeta{ 235 | Name: "test", 236 | }, 237 | Spec: corev1.PodSpec{ 238 | Containers: []corev1.Container{ 239 | {Image: "image-registry.openshift-image-registry.svc:5000/openshift/cli:latest"}, 240 | }, 241 | InitContainers: []corev1.Container{ 242 | {Image: "ubuntu"}, 243 | }, 244 | }, 245 | Status: corev1.PodStatus{}, 246 | }, 247 | expected: true, 248 | }, 249 | { 250 | name: "test pod with cli image in containers and initcontainers", 251 | pod: &corev1.Pod{ 252 | ObjectMeta: metav1.ObjectMeta{ 253 | Name: "test", 254 | }, 255 | Spec: corev1.PodSpec{ 256 | Containers: []corev1.Container{ 257 | {Image: "image-registry.openshift-image-registry.svc:5000/openshift/cli:latest"}, 258 | }, 259 | InitContainers: []corev1.Container{ 260 | {Image: "image-registry.openshift-image-registry.svc:5000/openshift/cli:latest"}, 261 | }, 262 | }, 263 | Status: corev1.PodStatus{}, 264 | }, 265 | expected: true, 266 | }, 267 | } 268 | for _, test := range tests { 269 | actual := podContainsContainerRegexMatch(test.pod) 270 | if actual != test.expected { 271 | t.Errorf("TestPodContainsContainerRegexMatch() %s -\n pod: %v \n actual: %t\n expected: %t\n", test.name, test.pod, actual, test.expected) 272 | } 273 | } 274 | 275 | } 276 | -------------------------------------------------------------------------------- /pkg/webhooks/prometheusrule/prometheusrule.go: -------------------------------------------------------------------------------- 1 | package prometheusrule 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "regexp" 7 | "slices" 8 | 9 | hookconfig "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/config" 10 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 11 | 12 | admissionregv1 "k8s.io/api/admissionregistration/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/runtime" 15 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 16 | 17 | logf "sigs.k8s.io/controller-runtime/pkg/log" 18 | ) 19 | 20 | const ( 21 | WebhookName string = "prometheusrule-validation" 22 | docString string = `Managed OpenShift Customers may not create PrometheusRule in namespaces managed by Red Hat.` 23 | ) 24 | 25 | var ( 26 | timeout int32 = 2 27 | allowedUsers = []string{"kube:admin", "system:admin", "backplane-cluster-admin"} 28 | sreAdminGroups = []string{"system:serviceaccounts:openshift-backplane-srep"} 29 | privilegedServiceAccountGroupsRe = regexp.MustCompile(utils.PrivilegedServiceAccountGroups) 30 | privilegedLabels = map[string]string{"app.kubernetes.io/name": "stackrox"} 31 | scope = admissionregv1.NamespacedScope 32 | rules = []admissionregv1.RuleWithOperations{ 33 | { 34 | Operations: []admissionregv1.OperationType{ 35 | admissionregv1.Create, 36 | admissionregv1.Update, 37 | admissionregv1.Delete, 38 | }, 39 | Rule: admissionregv1.Rule{ 40 | APIGroups: []string{"monitoring.coreos.com"}, 41 | APIVersions: []string{"*"}, 42 | Resources: []string{"prometheusrules"}, 43 | Scope: &scope, 44 | }, 45 | }, 46 | } 47 | log = logf.Log.WithName(WebhookName) 48 | 49 | // These namespaces are partially managed by Red Hat SRE, however we allow customers to define PrometheusRules in them. 50 | privilegedNamespacesAllowed = []string{"openshift-customer-monitoring", "openshift-user-workload-monitoring"} 51 | ) 52 | 53 | // prometheusruleWebhook validates a prometheusRule change 54 | type prometheusruleWebhook struct { 55 | s runtime.Scheme 56 | } 57 | 58 | // We just need a runtime object to get the namespace 59 | type prometheusRule struct { 60 | runtime.Object 61 | metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` 62 | } 63 | 64 | // NewWebhook creates the new webhook 65 | func NewWebhook() *prometheusruleWebhook { 66 | scheme := runtime.NewScheme() 67 | return &prometheusruleWebhook{ 68 | s: *scheme, 69 | } 70 | } 71 | 72 | // Authorized implements Webhook interface 73 | func (s *prometheusruleWebhook) Authorized(request admissionctl.Request) admissionctl.Response { 74 | return s.authorized(request) 75 | } 76 | 77 | func (s *prometheusruleWebhook) authorized(request admissionctl.Request) admissionctl.Response { 78 | var ret admissionctl.Response 79 | 80 | pr, err := s.renderPrometheusRule(request) 81 | if err != nil { 82 | log.Error(err, "Couldn't render a PrometheusRule from the incoming request") 83 | return admissionctl.Errored(http.StatusBadRequest, err) 84 | } 85 | 86 | // This block covers the denial flow for PrivilegedNamespaces, excluding some special case namespaces. 87 | if hookconfig.IsPrivilegedNamespace(pr.GetNamespace()) && !slices.Contains(privilegedNamespacesAllowed, pr.GetNamespace()) { 88 | log.Info(fmt.Sprintf("%s operation detected on managed namespace: %s", request.Operation, pr.GetNamespace())) 89 | if isAllowedUser(request) { 90 | ret = admissionctl.Allowed(fmt.Sprintf("User can do operations on PrometheusRules")) 91 | ret.UID = request.AdmissionRequest.UID 92 | return ret 93 | } 94 | for _, group := range request.UserInfo.Groups { 95 | if privilegedServiceAccountGroupsRe.Match([]byte(group)) { 96 | ret = admissionctl.Allowed("Privileged service accounts do operations on PrometheusRules") 97 | ret.UID = request.AdmissionRequest.UID 98 | return ret 99 | } 100 | } 101 | 102 | // TODO: [OSD-20025] Remove this exception after MON-3518 is completed 103 | if hasPrivilegedLabel(pr) { 104 | ret = admissionctl.Allowed("PrometheusRules with privileged labels can be modified") 105 | ret.UID = request.AdmissionRequest.UID 106 | return ret 107 | } 108 | 109 | ret = admissionctl.Denied(fmt.Sprintf("Prevented from accessing Red Hat managed resources. This is in an effort to prevent harmful actions that may cause unintended consequences or affect the stability of the cluster. If you have any questions about this, please reach out to Red Hat support at https://access.redhat.com/support")) 110 | ret.UID = request.AdmissionRequest.UID 111 | return ret 112 | } 113 | 114 | log.Info("Allowing access") 115 | ret = admissionctl.Allowed("Non managed namespace") 116 | ret.UID = request.AdmissionRequest.UID 117 | return ret 118 | } 119 | 120 | // isAllowedUser checks if the user or group is allowed to perform the action 121 | func isAllowedUser(request admissionctl.Request) bool { 122 | if slices.Contains(allowedUsers, request.UserInfo.Username) { 123 | return true 124 | } 125 | 126 | for _, group := range sreAdminGroups { 127 | if slices.Contains(request.UserInfo.Groups, group) { 128 | return true 129 | } 130 | } 131 | 132 | return false 133 | } 134 | 135 | // hasPrivilegedLabel checks if the rendered rule's labels match one of the privilegedLabels 136 | func hasPrivilegedLabel(rule *prometheusRule) bool { 137 | for key, val := range privilegedLabels { 138 | if rule.Labels[key] == val { 139 | return true 140 | } 141 | } 142 | return false 143 | } 144 | 145 | // GetURI implements Webhook interface 146 | func (s *prometheusruleWebhook) GetURI() string { 147 | return "/" + WebhookName 148 | } 149 | 150 | // Validate implements Webhook interface 151 | func (s *prometheusruleWebhook) Validate(request admissionctl.Request) bool { 152 | valid := true 153 | valid = valid && (request.UserInfo.Username != "") 154 | valid = valid && (request.Kind.Kind == "PrometheusRule") 155 | 156 | return valid 157 | } 158 | 159 | func (s *prometheusruleWebhook) renderPrometheusRule(req admissionctl.Request) (*prometheusRule, error) { 160 | decoder := admissionctl.NewDecoder(&s.s) 161 | prometheusRule := &prometheusRule{} 162 | 163 | var err error 164 | if len(req.OldObject.Raw) > 0 { 165 | err = decoder.DecodeRaw(req.OldObject, prometheusRule) 166 | } else { 167 | err = decoder.Decode(req, prometheusRule) 168 | } 169 | if err != nil { 170 | return nil, err 171 | } 172 | return prometheusRule, nil 173 | } 174 | 175 | // Name implements Webhook interface 176 | func (s *prometheusruleWebhook) Name() string { 177 | return WebhookName 178 | } 179 | 180 | // FailurePolicy implements Webhook interface 181 | func (s *prometheusruleWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 182 | return admissionregv1.Ignore 183 | } 184 | 185 | // MatchPolicy implements Webhook interface 186 | func (s *prometheusruleWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 187 | return admissionregv1.Equivalent 188 | } 189 | 190 | // Rules implements Webhook interface 191 | func (s *prometheusruleWebhook) Rules() []admissionregv1.RuleWithOperations { 192 | return rules 193 | } 194 | 195 | // ObjectSelector implements Webhook interface 196 | func (s *prometheusruleWebhook) ObjectSelector() *metav1.LabelSelector { 197 | return nil 198 | } 199 | 200 | // SideEffects implements Webhook interface 201 | func (s *prometheusruleWebhook) SideEffects() admissionregv1.SideEffectClass { 202 | return admissionregv1.SideEffectClassNone 203 | } 204 | 205 | // TimeoutSeconds implements Webhook interface 206 | func (s *prometheusruleWebhook) TimeoutSeconds() int32 { 207 | return timeout 208 | } 209 | 210 | // Doc implements Webhook interface 211 | func (s *prometheusruleWebhook) Doc() string { 212 | return (docString) 213 | } 214 | 215 | // SyncSetLabelSelector returns the label selector to use in the SyncSet. 216 | // Return utils.DefaultLabelSelector() to stick with the default 217 | func (s *prometheusruleWebhook) SyncSetLabelSelector() metav1.LabelSelector { 218 | return utils.DefaultLabelSelector() 219 | } 220 | 221 | func (s *prometheusruleWebhook) ClassicEnabled() bool { return true } 222 | 223 | func (s *prometheusruleWebhook) HypershiftEnabled() bool { return false } 224 | -------------------------------------------------------------------------------- /pkg/webhooks/register.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | admissionregv1 "k8s.io/api/admissionregistration/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 7 | ) 8 | 9 | type RegisteredWebhooks map[string]WebhookFactory 10 | 11 | // Webhooks are all registered webhooks mapping name to hook 12 | var Webhooks = RegisteredWebhooks{} 13 | 14 | // Webhook interface 15 | type Webhook interface { 16 | // Authorized will determine if the request is allowed 17 | Authorized(request admissionctl.Request) admissionctl.Response 18 | // GetURI returns the URI for the webhook 19 | GetURI() string 20 | // Validate will validate the incoming request 21 | Validate(admissionctl.Request) bool 22 | // Name is the name of the webhook 23 | Name() string 24 | // FailurePolicy is how the hook config should react if k8s can't access it 25 | // https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy 26 | FailurePolicy() admissionregv1.FailurePolicyType 27 | // MatchPolicy mirrors validatingwebhookconfiguration.webhooks[].matchPolicy. 28 | // If it is important to the webhook, be sure to check subResource vs 29 | // requestSubResource. 30 | // https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy 31 | MatchPolicy() admissionregv1.MatchPolicyType 32 | // Rules is a slice of rules on which this hook should trigger 33 | Rules() []admissionregv1.RuleWithOperations 34 | // ObjectSelector uses a *metav1.LabelSelector to augment the webhook's 35 | // Rules() to match only on incoming requests which match the specific 36 | // LabelSelector. 37 | ObjectSelector() *metav1.LabelSelector 38 | // SideEffects are what side effects, if any, this hook has. Refer to 39 | // https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#side-effects 40 | SideEffects() admissionregv1.SideEffectClass 41 | // TimeoutSeconds returns an int32 representing how long to wait for this hook to complete 42 | // The timeout value must be between 1 and 30 seconds. 43 | // https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#timeouts 44 | TimeoutSeconds() int32 45 | // Doc returns a string for end-customer documentation purposes. 46 | Doc() string 47 | // SyncSetLabelSelector returns the label selector to use in the SyncSet. 48 | // Return utils.DefaultLabelSelector() to stick with the default 49 | SyncSetLabelSelector() metav1.LabelSelector 50 | // ClassicEnabled will return true if the webhook should be deployed to OSD/ROSA Classic clusters 51 | ClassicEnabled() bool 52 | // HypershiftEnabled will return true if the webhook should be deployed to ROSA HCP clusters 53 | HypershiftEnabled() bool 54 | } 55 | 56 | // WebhookFactory return a kind of Webhook 57 | type WebhookFactory func() Webhook 58 | 59 | // Register webhooks 60 | func Register(name string, input WebhookFactory) { 61 | Webhooks[name] = input 62 | } 63 | -------------------------------------------------------------------------------- /pkg/webhooks/scc/scc.go: -------------------------------------------------------------------------------- 1 | package scc 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "slices" 7 | 8 | securityv1 "github.com/openshift/api/security/v1" 9 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 10 | admissionv1 "k8s.io/api/admission/v1" 11 | admissionregv1 "k8s.io/api/admissionregistration/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | logf "sigs.k8s.io/controller-runtime/pkg/log" 15 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 16 | ) 17 | 18 | const ( 19 | WebhookName = "scc-validation" 20 | docString = `Managed OpenShift Customers may not modify the following default SCCs: %s` 21 | ) 22 | 23 | var ( 24 | timeout int32 = 2 25 | log = logf.Log.WithName(WebhookName) 26 | scope = admissionregv1.ClusterScope 27 | rules = []admissionregv1.RuleWithOperations{ 28 | { 29 | Operations: []admissionregv1.OperationType{"UPDATE", "DELETE"}, 30 | Rule: admissionregv1.Rule{ 31 | APIGroups: []string{"security.openshift.io"}, 32 | APIVersions: []string{"*"}, 33 | Resources: []string{"securitycontextconstraints"}, 34 | Scope: &scope, 35 | }, 36 | }, 37 | } 38 | allowedUsers = []string{ 39 | "system:serviceaccount:openshift-kube-apiserver-operator:kube-apiserver-operator", 40 | "system:serviceaccount:openshift-monitoring:cluster-monitoring-operator", 41 | "system:serviceaccount:openshift-cluster-version:default", 42 | "system:admin", 43 | } 44 | allowedGroups = []string{} 45 | defaultSCCs = []string{ 46 | "anyuid", 47 | "hostaccess", 48 | "hostmount-anyuid", 49 | "hostnetwork", 50 | "hostnetwork-v2", 51 | "node-exporter", 52 | "nonroot", 53 | "nonroot-v2", 54 | "privileged", 55 | "restricted", 56 | "restricted-v2", 57 | } 58 | ) 59 | 60 | type SCCWebHook struct { 61 | scheme *runtime.Scheme 62 | } 63 | 64 | // NewWebhook creates the new webhook 65 | func NewWebhook() *SCCWebHook { 66 | return &SCCWebHook{ 67 | scheme: runtime.NewScheme(), 68 | } 69 | } 70 | 71 | // Authorized implements Webhook interface 72 | func (s *SCCWebHook) Authorized(request admissionctl.Request) admissionctl.Response { 73 | return s.authorized(request) 74 | } 75 | 76 | func (s *SCCWebHook) authorized(request admissionctl.Request) admissionctl.Response { 77 | var ret admissionctl.Response 78 | 79 | scc, err := s.renderSCC(request) 80 | if err != nil { 81 | log.Error(err, "Couldn't render a SCC from the incoming request") 82 | return admissionctl.Errored(http.StatusBadRequest, err) 83 | } 84 | 85 | if isDefaultSCC(scc) && !isAllowedUserGroup(request) { 86 | switch request.Operation { 87 | case admissionv1.Delete: 88 | log.Info(fmt.Sprintf("Deleting operation detected on default SCC: %v", scc.Name)) 89 | ret = admissionctl.Denied(fmt.Sprintf("Deleting default SCCs %v is not allowed", defaultSCCs)) 90 | ret.UID = request.AdmissionRequest.UID 91 | return ret 92 | case admissionv1.Update: 93 | log.Info(fmt.Sprintf("Updating operation detected on default SCC: %v", scc.Name)) 94 | ret = admissionctl.Denied(fmt.Sprintf("Modifying default SCCs %v is not allowed", defaultSCCs)) 95 | ret.UID = request.AdmissionRequest.UID 96 | return ret 97 | } 98 | } 99 | 100 | ret = admissionctl.Allowed("Request is allowed") 101 | ret.UID = request.AdmissionRequest.UID 102 | return ret 103 | } 104 | 105 | // renderSCC render the SCC object from the requests 106 | func (s *SCCWebHook) renderSCC(request admissionctl.Request) (*securityv1.SecurityContextConstraints, error) { 107 | decoder := admissionctl.NewDecoder(s.scheme) 108 | scc := &securityv1.SecurityContextConstraints{} 109 | 110 | var err error 111 | if len(request.OldObject.Raw) > 0 { 112 | err = decoder.DecodeRaw(request.OldObject, scc) 113 | } 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | return scc, nil 119 | } 120 | 121 | // isAllowedUserGroup checks if the user or group is allowed to perform the action 122 | func isAllowedUserGroup(request admissionctl.Request) bool { 123 | if slices.Contains(allowedUsers, request.UserInfo.Username) { 124 | return true 125 | } 126 | 127 | for _, group := range allowedGroups { 128 | if slices.Contains(request.UserInfo.Groups, group) { 129 | return true 130 | } 131 | } 132 | 133 | return false 134 | } 135 | 136 | // isDefaultSCC checks if the request is going to operate on the SCC in the 137 | // default list 138 | func isDefaultSCC(scc *securityv1.SecurityContextConstraints) bool { 139 | for _, s := range defaultSCCs { 140 | if scc.Name == s { 141 | return true 142 | } 143 | } 144 | return false 145 | } 146 | 147 | // GetURI implements Webhook interface 148 | func (s *SCCWebHook) GetURI() string { 149 | return "/" + WebhookName 150 | } 151 | 152 | // Validate implements Webhook interface 153 | func (s *SCCWebHook) Validate(request admissionctl.Request) bool { 154 | valid := true 155 | valid = valid && (request.UserInfo.Username != "") 156 | valid = valid && (request.Kind.Kind == "SecurityContextConstraints") 157 | 158 | return valid 159 | } 160 | 161 | // Name implements Webhook interface 162 | func (s *SCCWebHook) Name() string { 163 | return WebhookName 164 | } 165 | 166 | // FailurePolicy implements Webhook interface 167 | func (s *SCCWebHook) FailurePolicy() admissionregv1.FailurePolicyType { 168 | return admissionregv1.Ignore 169 | } 170 | 171 | // MatchPolicy implements Webhook interface 172 | func (s *SCCWebHook) MatchPolicy() admissionregv1.MatchPolicyType { 173 | return admissionregv1.Equivalent 174 | } 175 | 176 | // Rules implements Webhook interface 177 | func (s *SCCWebHook) Rules() []admissionregv1.RuleWithOperations { 178 | return rules 179 | } 180 | 181 | // ObjectSelector implements Webhook interface 182 | func (s *SCCWebHook) ObjectSelector() *metav1.LabelSelector { 183 | return nil 184 | } 185 | 186 | // SideEffects implements Webhook interface 187 | func (s *SCCWebHook) SideEffects() admissionregv1.SideEffectClass { 188 | return admissionregv1.SideEffectClassNone 189 | } 190 | 191 | // TimeoutSeconds implements Webhook interface 192 | func (s *SCCWebHook) TimeoutSeconds() int32 { 193 | return timeout 194 | } 195 | 196 | // Doc implements Webhook interface 197 | func (s *SCCWebHook) Doc() string { 198 | return fmt.Sprintf(docString, defaultSCCs) 199 | } 200 | 201 | // SyncSetLabelSelector returns the label selector to use in the SyncSet. 202 | // Return utils.DefaultLabelSelector() to stick with the default 203 | func (s *SCCWebHook) SyncSetLabelSelector() metav1.LabelSelector { 204 | return utils.DefaultLabelSelector() 205 | } 206 | 207 | func (s *SCCWebHook) ClassicEnabled() bool { return true } 208 | 209 | func (s *SCCWebHook) HypershiftEnabled() bool { return true } 210 | -------------------------------------------------------------------------------- /pkg/webhooks/scc/scc_test.go: -------------------------------------------------------------------------------- 1 | package scc 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | admissionv1 "k8s.io/api/admission/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | 10 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/testutils" 11 | 12 | "k8s.io/apimachinery/pkg/runtime" 13 | ) 14 | 15 | type sccTestSuites struct { 16 | testID string 17 | targetSCC string 18 | username string 19 | operation admissionv1.Operation 20 | userGroups []string 21 | shouldBeAllowed bool 22 | } 23 | 24 | const testObjectRaw string = ` 25 | { 26 | "apiVersion": "security.openshift.io/v1", 27 | "kind": "SecurityContextConstraints", 28 | "metadata": { 29 | "name": "%s", 30 | "uid": "1234" 31 | } 32 | }` 33 | 34 | func createRawJSONString(name string) string { 35 | s := fmt.Sprintf(testObjectRaw, name) 36 | return s 37 | } 38 | 39 | func runSCCTests(t *testing.T, tests []sccTestSuites) { 40 | gvk := metav1.GroupVersionKind{ 41 | Group: "security.openshift.io", 42 | Version: "v1", 43 | Kind: "SecurityContextConstraints", 44 | } 45 | gvr := metav1.GroupVersionResource{ 46 | Group: "security.openshift.io", 47 | Version: "v1", 48 | Resource: "securitycontextcontraints", 49 | } 50 | 51 | for _, test := range tests { 52 | rawObjString := createRawJSONString(test.targetSCC) 53 | 54 | obj := runtime.RawExtension{ 55 | Raw: []byte(rawObjString), 56 | } 57 | 58 | oldObj := runtime.RawExtension{ 59 | Raw: []byte(rawObjString), 60 | } 61 | 62 | hook := NewWebhook() 63 | httprequest, err := testutils.CreateHTTPRequest(hook.GetURI(), 64 | test.testID, gvk, gvr, test.operation, test.username, test.userGroups, "", &obj, &oldObj) 65 | if err != nil { 66 | t.Fatalf("Expected no error, got %s", err.Error()) 67 | } 68 | 69 | response, err := testutils.SendHTTPRequest(httprequest, hook) 70 | if err != nil { 71 | t.Fatalf("Expected no error, got %s", err.Error()) 72 | } 73 | if response.UID == "" { 74 | t.Fatalf("No tracking UID associated with the response.") 75 | } 76 | 77 | if response.Allowed != test.shouldBeAllowed { 78 | t.Fatalf("Mismatch: %s (groups=%s) %s %s the scc. Test's expectation is that the user %s", test.username, test.userGroups, testutils.CanCanNot(response.Allowed), test.operation, testutils.CanCanNot(test.shouldBeAllowed)) 79 | } 80 | } 81 | } 82 | func TestUser(t *testing.T) { 83 | tests := []sccTestSuites{ 84 | { 85 | targetSCC: "hostnetwork", 86 | testID: "user-cant-delete-hostnetwork", 87 | username: "user1", 88 | operation: admissionv1.Delete, 89 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 90 | shouldBeAllowed: false, 91 | }, 92 | { 93 | targetSCC: "hostaccess", 94 | testID: "user-cant-delete-hostaccess", 95 | username: "user2", 96 | operation: admissionv1.Delete, 97 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 98 | shouldBeAllowed: false, 99 | }, 100 | { 101 | targetSCC: "anyuid", 102 | testID: "user-cant-delete-anyuid", 103 | username: "user3", 104 | operation: admissionv1.Delete, 105 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 106 | shouldBeAllowed: false, 107 | }, 108 | { 109 | targetSCC: "anyuid", 110 | testID: "user-cant-modify-hostnetwork", 111 | username: "user4", 112 | operation: admissionv1.Update, 113 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 114 | shouldBeAllowed: false, 115 | }, 116 | { 117 | targetSCC: "hostnetwork-v2", 118 | testID: "user-cant-delete-hostnetwork-v2", 119 | username: "user1", 120 | operation: admissionv1.Delete, 121 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 122 | shouldBeAllowed: false, 123 | }, 124 | { 125 | targetSCC: "testscc", 126 | testID: "user-can-modify-normal", 127 | username: "user1", 128 | operation: admissionv1.Update, 129 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 130 | shouldBeAllowed: true, 131 | }, 132 | { 133 | targetSCC: "hostaccess", 134 | testID: "allowed-user-can-modify-default", 135 | username: "system:serviceaccount:openshift-monitoring:cluster-monitoring-operator", 136 | operation: admissionv1.Update, 137 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 138 | shouldBeAllowed: true, 139 | }, 140 | { 141 | targetSCC: "hostaccess", 142 | testID: "allowed-system-admin-can-modify-default", 143 | username: "system:admin", 144 | operation: admissionv1.Update, 145 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 146 | shouldBeAllowed: true, 147 | }, 148 | { 149 | targetSCC: "testscc", 150 | testID: "user-can-delete-normal", 151 | username: "user1", 152 | operation: admissionv1.Delete, 153 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 154 | shouldBeAllowed: true, 155 | }, 156 | { 157 | targetSCC: "hostaccess", 158 | testID: "allowed-user-can-delete-default", 159 | username: "system:serviceaccount:openshift-monitoring:cluster-monitoring-operator", 160 | operation: admissionv1.Delete, 161 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 162 | shouldBeAllowed: true, 163 | }, 164 | { 165 | targetSCC: "privileged", 166 | testID: "osde2e-serviceaccounts-are-not-allowed", 167 | username: "system:serviceaccount:osde2e-abcde:osde2e-runner", 168 | operation: admissionv1.Update, 169 | userGroups: []string{"system:authenticated", "system:serviceaccounts:osde2e-abcde"}, 170 | shouldBeAllowed: false, 171 | }, 172 | { 173 | targetSCC: "anyuid", 174 | testID: "kube-apiserver-operator-allowed", 175 | username: "system:serviceaccount:openshift-kube-apiserver-operator:kube-apiserver-operator", 176 | operation: admissionv1.Update, 177 | userGroups: []string{}, 178 | shouldBeAllowed: true, 179 | }, 180 | } 181 | runSCCTests(t, tests) 182 | } 183 | -------------------------------------------------------------------------------- /pkg/webhooks/sdnmigration/sdnmigration.go: -------------------------------------------------------------------------------- 1 | package sdnmigration 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | 7 | configv1 "github.com/openshift/api/config/v1" 8 | admissionv1 "k8s.io/api/admission/v1" 9 | admissionregv1 "k8s.io/api/admissionregistration/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/runtime" 12 | logf "sigs.k8s.io/controller-runtime/pkg/log" 13 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 14 | 15 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 16 | ) 17 | 18 | const ( 19 | WebhookName string = "sdn-migration-validation" 20 | docString string = `Managed OpenShift customers may not modify the network config type because it can can degrade cluster operators and can interfere with OpenShift SRE monitoring.` 21 | overrideAnnotation string = "unsupported-red-hat-internal-testing" 22 | privilegedHiveUserAccount string = "system:admin" 23 | ) 24 | 25 | var ( 26 | log = logf.Log.WithName(WebhookName) 27 | privilegedServiceAccountsRe = regexp.MustCompile(utils.PrivilegedServiceAccountGroups) 28 | 29 | scope = admissionregv1.ClusterScope 30 | rules = []admissionregv1.RuleWithOperations{ 31 | { 32 | Operations: []admissionregv1.OperationType{"UPDATE"}, 33 | Rule: admissionregv1.Rule{ 34 | APIGroups: []string{"config.openshift.io"}, 35 | APIVersions: []string{"*"}, 36 | Resources: []string{"networks"}, 37 | Scope: &scope, 38 | }, 39 | }, 40 | } 41 | ) 42 | 43 | type NetworkConfigWebhook struct { 44 | s runtime.Scheme 45 | } 46 | 47 | // Authorized will determine if the request is allowed 48 | func (w *NetworkConfigWebhook) Authorized(request admissionctl.Request) admissionctl.Response { 49 | // We are doing this check to ensure that hive can trigger the 50 | // migration process. Once a cluster install completes successfully, 51 | // the admin password and kubeconfig will be uploaded as secrets and linked to the ClusterDeployment resource 52 | // on hive under the cluster namespace. Hive uses this credentials for the user "admin-kubeconfig-signer" 53 | // in order to call the api on the clusters and execute administrative tasks. 54 | if request.UserInfo.Username == privilegedHiveUserAccount { 55 | return utils.WebhookResponse(request, true, "Privileged user may access") 56 | } 57 | 58 | // allow if modified by an allow listed service account 59 | for _, group := range request.UserInfo.Groups { 60 | if privilegedServiceAccountsRe.Match([]byte(group)) { 61 | return utils.WebhookResponse(request, true, "Privileged service accounts may access") 62 | } 63 | } 64 | 65 | if request.Operation == admissionv1.Update { 66 | decoder := admissionctl.NewDecoder(&w.s) 67 | object := &configv1.Network{} 68 | oldObject := &configv1.Network{} 69 | 70 | if err := decoder.Decode(request, object); err != nil { 71 | log.Error(err, "failed to render a Network from request.Object") 72 | ret := admissionctl.Errored(http.StatusBadRequest, err) 73 | ret.UID = request.AdmissionRequest.UID 74 | return ret 75 | } 76 | if err := decoder.DecodeRaw(request.OldObject, oldObject); err != nil { 77 | log.Error(err, "failed to render a Network from request.OldObject") 78 | ret := admissionctl.Errored(http.StatusBadRequest, err) 79 | ret.UID = request.AdmissionRequest.UID 80 | return ret 81 | } 82 | 83 | if v, ok := oldObject.Annotations[overrideAnnotation]; ok && v == "true" { 84 | return utils.WebhookResponse(request, true, "`red-hat-internal-testing: true` annotation present") 85 | } 86 | 87 | if object.Spec.NetworkType != oldObject.Status.NetworkType { 88 | return utils.WebhookResponse(request, false, "Changing the network type is not allowed") 89 | } 90 | 91 | return utils.WebhookResponse(request, true, "allowed action") 92 | } 93 | 94 | return utils.WebhookResponse(request, false, "Changing the network type is not allowed") 95 | } 96 | 97 | // GetURI returns the URI for the webhook 98 | func (w *NetworkConfigWebhook) GetURI() string { return "/sdnmigration-validation" } 99 | 100 | // Validate will validate the incoming request 101 | func (w *NetworkConfigWebhook) Validate(req admissionctl.Request) bool { 102 | valid := true 103 | valid = valid && (req.UserInfo.Username != "") 104 | valid = valid && (req.Kind.Kind == "Network") 105 | 106 | return valid 107 | } 108 | 109 | // Name is the name of the webhook 110 | func (w *NetworkConfigWebhook) Name() string { return WebhookName } 111 | 112 | // FailurePolicy is how the hook config should react if k8s can't access it 113 | func (w *NetworkConfigWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 114 | return admissionregv1.Ignore 115 | } 116 | 117 | // MatchPolicy mirrors validatingwebhookconfiguration.webhooks[].matchPolicy 118 | // If it is important to the webhook, be sure to check subResource vs 119 | // requestSubResource. 120 | func (w *NetworkConfigWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 121 | return admissionregv1.Equivalent 122 | } 123 | 124 | // Rules is a slice of rules on which this hook should trigger 125 | func (w *NetworkConfigWebhook) Rules() []admissionregv1.RuleWithOperations { return rules } 126 | 127 | // ObjectSelector uses a *metav1.LabelSelector to augment the webhook's 128 | // Rules() to match only on incoming requests which match the specific 129 | // LabelSelector. 130 | func (w *NetworkConfigWebhook) ObjectSelector() *metav1.LabelSelector { return nil } 131 | 132 | // SideEffects are what side effects, if any, this hook has. Refer to 133 | // https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#side-effects 134 | func (w *NetworkConfigWebhook) SideEffects() admissionregv1.SideEffectClass { 135 | return admissionregv1.SideEffectClassNone 136 | } 137 | 138 | // TimeoutSeconds returns an int32 representing how long to wait for this hook to complete 139 | func (w *NetworkConfigWebhook) TimeoutSeconds() int32 { return 2 } 140 | 141 | // Doc returns a string for end-customer documentation purposes. 142 | func (w *NetworkConfigWebhook) Doc() string { return docString } 143 | 144 | // SyncSetLabelSelector returns the label selector to use in the SyncSet. 145 | // Return utils.DefaultLabelSelector() to stick with the default 146 | func (w *NetworkConfigWebhook) SyncSetLabelSelector() metav1.LabelSelector { 147 | return utils.DefaultLabelSelector() 148 | } 149 | 150 | func (w *NetworkConfigWebhook) ClassicEnabled() bool { return true } 151 | 152 | // HypershiftEnabled will return boolean value for hypershift enabled configurations 153 | func (w *NetworkConfigWebhook) HypershiftEnabled() bool { return false } 154 | 155 | // NewWebhook creates a new webhook 156 | func NewWebhook() *NetworkConfigWebhook { 157 | scheme := runtime.NewScheme() 158 | 159 | return &NetworkConfigWebhook{ 160 | s: *scheme, 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /pkg/webhooks/serviceaccount/serviceaccount.go: -------------------------------------------------------------------------------- 1 | package serviceaccount 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "slices" 8 | "strings" 9 | 10 | admissionv1 "k8s.io/api/admission/v1" 11 | admissionregv1 "k8s.io/api/admissionregistration/v1" 12 | corev1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/runtime" 15 | logf "sigs.k8s.io/controller-runtime/pkg/log" 16 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 17 | 18 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/config" 19 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 20 | ) 21 | 22 | const ( 23 | WebhookName string = "serviceaccount-validation" 24 | docString string = `Managed OpenShift Customers may not delete the service accounts under the managed namespaces。` 25 | ) 26 | 27 | var ( 28 | timeout int32 = 2 29 | log = logf.Log.WithName(WebhookName) 30 | scope = admissionregv1.NamespacedScope 31 | rules = []admissionregv1.RuleWithOperations{ 32 | { 33 | Operations: []admissionregv1.OperationType{"DELETE"}, 34 | Rule: admissionregv1.Rule{ 35 | APIGroups: []string{""}, 36 | APIVersions: []string{"v1"}, 37 | Resources: []string{"serviceaccounts"}, 38 | Scope: &scope, 39 | }, 40 | }, 41 | } 42 | allowedUsers = []string{ 43 | "backplane-cluster-admin", 44 | } 45 | allowedGroups = []string{ 46 | "system:serviceaccounts:openshift-backplane-srep", 47 | } 48 | allowedServiceAccounts = []string{ 49 | "builder", 50 | "default", 51 | "deployer", 52 | } 53 | exceptionNamespaces = []string{ 54 | "openshift-logging", 55 | "openshift-user-workload-monitoring", 56 | "openshift-operators", 57 | } 58 | ) 59 | 60 | type serviceAccountWebhook struct { 61 | s runtime.Scheme 62 | } 63 | 64 | // NewWebhook creates the new webhook 65 | func NewWebhook() *serviceAccountWebhook { 66 | scheme := runtime.NewScheme() 67 | err := admissionv1.AddToScheme(scheme) 68 | if err != nil { 69 | log.Error(err, "Fail adding admissionsv1 scheme to serviceAccountWebhook") 70 | os.Exit(1) 71 | } 72 | err = corev1.AddToScheme(scheme) 73 | if err != nil { 74 | log.Error(err, "Fail adding corev1 scheme to serviceAccountWebhook") 75 | os.Exit(1) 76 | } 77 | 78 | return &serviceAccountWebhook{ 79 | s: *scheme, 80 | } 81 | } 82 | 83 | // Authorized implements Webhook interface 84 | func (s *serviceAccountWebhook) Authorized(request admissionctl.Request) admissionctl.Response { 85 | return s.authorized(request) 86 | } 87 | 88 | func (s *serviceAccountWebhook) authorized(request admissionctl.Request) admissionctl.Response { 89 | var ret admissionctl.Response 90 | 91 | if request.AdmissionRequest.UserInfo.Username == "system:unauthenticated" { 92 | // This could highlight a significant problem with RBAC since an 93 | // unauthenticated user should have no permissions. 94 | log.Info("system:unauthenticated made a webhook request. Check RBAC rules", "request", request.AdmissionRequest) 95 | ret = admissionctl.Denied("Unauthenticated") 96 | ret.UID = request.AdmissionRequest.UID 97 | return ret 98 | } 99 | if strings.HasPrefix(request.AdmissionRequest.UserInfo.Username, "system:") { 100 | ret = admissionctl.Allowed("authenticated system: users are allowed") 101 | ret.UID = request.AdmissionRequest.UID 102 | return ret 103 | } 104 | if strings.HasPrefix(request.AdmissionRequest.UserInfo.Username, "kube:") { 105 | ret = admissionctl.Allowed("kube: users are allowed") 106 | ret.UID = request.AdmissionRequest.UID 107 | return ret 108 | } 109 | 110 | sa, err := s.renderServiceAccount(request) 111 | if err != nil { 112 | log.Error(err, "Couldn't render a service account from the incoming request") 113 | return admissionctl.Errored(http.StatusBadRequest, err) 114 | } 115 | 116 | if isProtectedNamespace(request) && !isAllowedUserGroup(request) { 117 | if request.Operation == admissionv1.Delete && !isAllowedServiceAccount(sa) { 118 | log.Info(fmt.Sprintf("Deleting operation detected on proteced serviceaccount: %v", sa.Name)) 119 | ret = admissionctl.Denied(fmt.Sprintf("Deleting protected service account under namespace %v is not allowed", request.Namespace)) 120 | ret.UID = request.AdmissionRequest.UID 121 | return ret 122 | } 123 | } 124 | 125 | ret = admissionctl.Allowed("Request is allowed") 126 | ret.UID = request.AdmissionRequest.UID 127 | return ret 128 | } 129 | 130 | // renderServiceAccount render the serviceaccount object from the requests 131 | func (s *serviceAccountWebhook) renderServiceAccount(request admissionctl.Request) (*corev1.ServiceAccount, error) { 132 | decoder := admissionctl.NewDecoder(&s.s) 133 | sa := &corev1.ServiceAccount{} 134 | 135 | var err error 136 | if len(request.OldObject.Raw) > 0 { 137 | err = decoder.DecodeRaw(request.OldObject, sa) 138 | } 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | return sa, nil 144 | } 145 | 146 | // isAllowedUserGroup checks if the user or group is allowed to perform the action 147 | func isAllowedUserGroup(request admissionctl.Request) bool { 148 | if slices.Contains(allowedUsers, request.UserInfo.Username) { 149 | return true 150 | } 151 | 152 | for _, group := range allowedGroups { 153 | if slices.Contains(request.UserInfo.Groups, group) { 154 | return true 155 | } 156 | } 157 | 158 | return false 159 | } 160 | 161 | // isProtectedNamespace checks if the request is going to operate on the serviceaccount in the 162 | // protected namespace list 163 | func isProtectedNamespace(request admissionctl.Request) bool { 164 | ns := request.Namespace 165 | 166 | if config.IsPrivilegedNamespace(ns) && !slices.Contains(exceptionNamespaces, ns) { 167 | return true 168 | } 169 | return false 170 | } 171 | 172 | func isAllowedServiceAccount(sa *corev1.ServiceAccount) bool { 173 | for _, s := range allowedServiceAccounts { 174 | if sa.Name == s { 175 | return true 176 | } 177 | } 178 | 179 | return false 180 | } 181 | 182 | // GetURI implements Webhook interface 183 | func (s *serviceAccountWebhook) GetURI() string { 184 | return "/" + WebhookName 185 | } 186 | 187 | // Validate implements Webhook interface 188 | func (s *serviceAccountWebhook) Validate(request admissionctl.Request) bool { 189 | valid := true 190 | valid = valid && (request.UserInfo.Username != "") 191 | valid = valid && (request.Kind.Kind == "ServiceAccount") 192 | 193 | return valid 194 | } 195 | 196 | // Name implements Webhook interface 197 | func (s *serviceAccountWebhook) Name() string { 198 | return WebhookName 199 | } 200 | 201 | // FailurePolicy implements Webhook interface 202 | func (s *serviceAccountWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 203 | return admissionregv1.Ignore 204 | } 205 | 206 | // MatchPolicy implements Webhook interface 207 | func (s *serviceAccountWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 208 | return admissionregv1.Equivalent 209 | } 210 | 211 | // Rules implements Webhook interface 212 | func (s *serviceAccountWebhook) Rules() []admissionregv1.RuleWithOperations { 213 | return rules 214 | } 215 | 216 | // ObjectSelector implements Webhook interface 217 | func (s *serviceAccountWebhook) ObjectSelector() *metav1.LabelSelector { 218 | return nil 219 | } 220 | 221 | // SideEffects implements Webhook interface 222 | func (s *serviceAccountWebhook) SideEffects() admissionregv1.SideEffectClass { 223 | return admissionregv1.SideEffectClassNone 224 | } 225 | 226 | // TimeoutSeconds implements Webhook interface 227 | func (s *serviceAccountWebhook) TimeoutSeconds() int32 { 228 | return timeout 229 | } 230 | 231 | // Doc implements Webhook interface 232 | func (s *serviceAccountWebhook) Doc() string { 233 | return fmt.Sprintf(docString) 234 | } 235 | 236 | // SyncSetLabelSelector returns the label selector to use in the SyncSet. 237 | // Return utils.DefaultLabelSelector() to stick with the default 238 | func (s *serviceAccountWebhook) SyncSetLabelSelector() metav1.LabelSelector { 239 | return utils.DefaultLabelSelector() 240 | } 241 | 242 | func (s *serviceAccountWebhook) ClassicEnabled() bool { return true } 243 | 244 | func (s *serviceAccountWebhook) HypershiftEnabled() bool { return true } 245 | -------------------------------------------------------------------------------- /pkg/webhooks/serviceaccount/serviceaccount_test.go: -------------------------------------------------------------------------------- 1 | package serviceaccount 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | admissionv1 "k8s.io/api/admission/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | 11 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/testutils" 12 | ) 13 | 14 | type serviceAccountTestSuites struct { 15 | testID string 16 | targetSA string 17 | username string 18 | operation admissionv1.Operation 19 | userGroups []string 20 | namespace string 21 | shouldBeAllowed bool 22 | } 23 | 24 | const testObjectRaw string = ` 25 | { 26 | "apiVersion": "v1", 27 | "kind": "ServiceAccount", 28 | "metadata": { 29 | "name": "%s", 30 | "namespace": "%s", 31 | "uid": "1234" 32 | } 33 | }` 34 | 35 | func createRawJSONString(name, namespace string) string { 36 | s := fmt.Sprintf(testObjectRaw, name, namespace) 37 | return s 38 | } 39 | 40 | func runServiceAccountTests(t *testing.T, tests []serviceAccountTestSuites) { 41 | gvk := metav1.GroupVersionKind{ 42 | Group: "", 43 | Version: "v1", 44 | Kind: "ServiceAccount", 45 | } 46 | gvr := metav1.GroupVersionResource{ 47 | Group: "", 48 | Version: "v1", 49 | Resource: "serviceaccounts", 50 | } 51 | 52 | for _, test := range tests { 53 | rawObjString := createRawJSONString(test.targetSA, test.namespace) 54 | 55 | obj := runtime.RawExtension{ 56 | Raw: []byte(rawObjString), 57 | } 58 | 59 | oldObj := runtime.RawExtension{ 60 | Raw: []byte(rawObjString), 61 | } 62 | 63 | hook := NewWebhook() 64 | httpRequest, err := testutils.CreateHTTPRequest(hook.GetURI(), 65 | test.testID, gvk, gvr, test.operation, test.username, test.userGroups, test.namespace, &obj, &oldObj) 66 | if err != nil { 67 | t.Fatalf("Expected no error, got %s", err.Error()) 68 | } 69 | 70 | response, err := testutils.SendHTTPRequest(httpRequest, hook) 71 | if err != nil { 72 | t.Fatalf("Expected no error, got %s", err.Error()) 73 | } 74 | if response.UID == "" { 75 | t.Fatalf("No tracking UID associated with the response.") 76 | } 77 | 78 | if response.Allowed != test.shouldBeAllowed { 79 | t.Fatalf("Mismatch: %s (groups=%s) %s %s the serviceaccount. Test's expectation is that the user %s", test.username, test.userGroups, testutils.CanCanNot(response.Allowed), test.operation, testutils.CanCanNot(test.shouldBeAllowed)) 80 | } 81 | } 82 | } 83 | func TestSADeletion(t *testing.T) { 84 | tests := []serviceAccountTestSuites{ 85 | { 86 | targetSA: "whatever", 87 | testID: "user-cant-delete-protected-sa-in-protected-ns", 88 | username: "user1", 89 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 90 | namespace: "openshift-ingress-operator", 91 | operation: admissionv1.Delete, 92 | shouldBeAllowed: false, 93 | }, 94 | { 95 | targetSA: "default", 96 | testID: "user-can-delete-normal-sa-in-protected-ns", 97 | username: "user1", 98 | operation: admissionv1.Delete, 99 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 100 | namespace: "openshift-ingress-operator", 101 | shouldBeAllowed: true, 102 | }, 103 | { 104 | targetSA: "whatever", 105 | testID: "user-can-delete-sa-in-normal-ns", 106 | username: "user1", 107 | operation: admissionv1.Delete, 108 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 109 | namespace: "whatever", 110 | shouldBeAllowed: true, 111 | }, 112 | { 113 | targetSA: "whatever", 114 | testID: "user-can-delete-sa-in-exception-ns", 115 | username: "user1", 116 | operation: admissionv1.Delete, 117 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 118 | namespace: "openshift-operators", 119 | shouldBeAllowed: true, 120 | }, 121 | { 122 | targetSA: "whatever", 123 | testID: "sre-can-delete-sa-in-protected-ns", 124 | username: "user1", 125 | operation: admissionv1.Delete, 126 | userGroups: []string{"system:serviceaccounts:openshift-backplane-srep"}, 127 | namespace: "openshift-ingress-operator", 128 | shouldBeAllowed: true, 129 | }, 130 | { 131 | targetSA: "whatever", 132 | testID: "elevated-sre-can-delete-sa-in-protected-ns", 133 | username: "backplane-cluster-admin", 134 | operation: admissionv1.Delete, 135 | userGroups: []string{"system:authenticated", "system:authenticated:oauth"}, 136 | namespace: "openshift-ingress-operator", 137 | shouldBeAllowed: true, 138 | }, 139 | { 140 | targetSA: "whatever", 141 | testID: "kube-account-can-delete-sa-in-protected-ns", 142 | username: "kube:admin", 143 | operation: admissionv1.Delete, 144 | userGroups: []string{"system:authenticated"}, 145 | namespace: "openshift-ingress-operator", 146 | shouldBeAllowed: true, 147 | }, 148 | } 149 | runServiceAccountTests(t, tests) 150 | } 151 | -------------------------------------------------------------------------------- /pkg/webhooks/techpreviewnoupgrade/techpreviewnoupgrade.go: -------------------------------------------------------------------------------- 1 | package techpreviewnoupgrade 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | 8 | configv1 "github.com/openshift/api/config/v1" 9 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/utils" 10 | admissionv1 "k8s.io/api/admission/v1" 11 | admissionregv1 "k8s.io/api/admissionregistration/v1" 12 | corev1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/runtime" 15 | logf "sigs.k8s.io/controller-runtime/pkg/log" 16 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 17 | ) 18 | 19 | const ( 20 | WebhookName string = "techpreviewnoupgrade-validation" 21 | docString string = `Managed OpenShift Customers may not use TechPreviewNoUpgrade FeatureGate that could prevent any future ability to do a y-stream upgrade to their clusters.` 22 | ) 23 | 24 | var ( 25 | log = logf.Log.WithName(WebhookName) 26 | 27 | scope = admissionregv1.ClusterScope 28 | rules = []admissionregv1.RuleWithOperations{ 29 | { 30 | Operations: []admissionregv1.OperationType{admissionregv1.Create, admissionregv1.Update}, 31 | Rule: admissionregv1.Rule{ 32 | APIGroups: []string{"config.openshift.io"}, 33 | APIVersions: []string{"*"}, 34 | Resources: []string{"featuregates"}, 35 | Scope: &scope, 36 | }, 37 | }, 38 | } 39 | ) 40 | 41 | type TechPreviewNoUpgradeWebhook struct { 42 | s runtime.Scheme 43 | } 44 | 45 | func (s *TechPreviewNoUpgradeWebhook) ObjectSelector() *metav1.LabelSelector { return nil } 46 | 47 | func (s *TechPreviewNoUpgradeWebhook) Doc() string { 48 | return fmt.Sprintf(docString) 49 | } 50 | 51 | func (s *TechPreviewNoUpgradeWebhook) TimeoutSeconds() int32 { return 1 } 52 | 53 | func (s *TechPreviewNoUpgradeWebhook) MatchPolicy() admissionregv1.MatchPolicyType { 54 | return admissionregv1.Equivalent 55 | } 56 | 57 | func (s *TechPreviewNoUpgradeWebhook) Name() string { return WebhookName } 58 | 59 | func (s *TechPreviewNoUpgradeWebhook) FailurePolicy() admissionregv1.FailurePolicyType { 60 | return admissionregv1.Ignore 61 | } 62 | 63 | func (s *TechPreviewNoUpgradeWebhook) Rules() []admissionregv1.RuleWithOperations { return rules } 64 | 65 | func (s *TechPreviewNoUpgradeWebhook) GetURI() string { return "/" + WebhookName } 66 | 67 | func (s *TechPreviewNoUpgradeWebhook) SideEffects() admissionregv1.SideEffectClass { 68 | return admissionregv1.SideEffectClassNone 69 | } 70 | 71 | func (s *TechPreviewNoUpgradeWebhook) Validate(req admissionctl.Request) bool { 72 | valid := true 73 | valid = valid && (req.UserInfo.Username != "") 74 | valid = valid && (req.Kind.Kind == "FeatureGate") 75 | 76 | return valid 77 | } 78 | 79 | func (s *TechPreviewNoUpgradeWebhook) Authorized(request admissionctl.Request) admissionctl.Response { 80 | return s.authorized(request) 81 | } 82 | 83 | func (s *TechPreviewNoUpgradeWebhook) SyncSetLabelSelector() metav1.LabelSelector { 84 | return utils.DefaultLabelSelector() 85 | } 86 | 87 | func (s *TechPreviewNoUpgradeWebhook) ClassicEnabled() bool { return true } 88 | 89 | func (s *TechPreviewNoUpgradeWebhook) HypershiftEnabled() bool { return true } 90 | 91 | func (s *TechPreviewNoUpgradeWebhook) renderFeatureGate(request admissionctl.Request) (*configv1.FeatureGate, error) { 92 | decoder := admissionctl.NewDecoder(&s.s) 93 | featureGate := &configv1.FeatureGate{} 94 | 95 | // Check the incoming featureGate for TechPreviewNoUpgrade 96 | err := decoder.DecodeRaw(request.Object, featureGate) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return featureGate, nil 102 | } 103 | 104 | func (s *TechPreviewNoUpgradeWebhook) authorized(request admissionctl.Request) admissionctl.Response { 105 | var ret admissionctl.Response 106 | 107 | featureGate, err := s.renderFeatureGate(request) 108 | 109 | if err != nil { 110 | log.Error(err, "Couldn't render a FeatureGate from the incoming request") 111 | 112 | ret = admissionctl.Errored(http.StatusBadRequest, err) 113 | ret.UID = request.AdmissionRequest.UID 114 | 115 | return ret 116 | } 117 | 118 | if featureGate != nil && featureGate.Spec.FeatureSet == "TechPreviewNoUpgrade" { 119 | log.Info("Not allowing access because of TechPreviewNoUpgrade Feature Gate", "request", request.AdmissionRequest) 120 | 121 | ret = admissionctl.Denied("The TechPreviewNoUpgrade Feature Gate is not allowed") 122 | ret.UID = request.AdmissionRequest.UID 123 | 124 | return ret 125 | } 126 | 127 | log.Info("Allowing access", "request", request.AdmissionRequest) 128 | 129 | ret = admissionctl.Allowed("FeatureGate operation is allowed") 130 | ret.UID = request.AdmissionRequest.UID 131 | 132 | return ret 133 | } 134 | 135 | func NewWebhook() *TechPreviewNoUpgradeWebhook { 136 | scheme := runtime.NewScheme() 137 | 138 | err := admissionv1.AddToScheme(scheme) 139 | if err != nil { 140 | log.Error(err, "Fail adding admissionsv1 scheme to TechPreviewNoUpgradeWebhook") 141 | os.Exit(1) 142 | } 143 | err = corev1.AddToScheme(scheme) 144 | if err != nil { 145 | log.Error(err, "Fail adding corev1 scheme to TechPreviewNoUpgradeWebhook") 146 | os.Exit(1) 147 | } 148 | 149 | return &TechPreviewNoUpgradeWebhook{ 150 | s: *scheme, 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /pkg/webhooks/techpreviewnoupgrade/techpreviewnoupgrade_test.go: -------------------------------------------------------------------------------- 1 | package techpreviewnoupgrade_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | admissionv1 "k8s.io/api/admission/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | 11 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/testutils" 12 | "github.com/thoseaunt/managed-cluster-validating-webhooks/pkg/webhooks/techpreviewnoupgrade" 13 | ) 14 | 15 | type techpreviewnoupgradeTestSuite struct { 16 | testName string 17 | testID string 18 | username string 19 | userGroups []string 20 | operation admissionv1.Operation 21 | featureSet string 22 | shouldBeAllowed bool 23 | } 24 | 25 | const testObjectRaw string = ` 26 | { 27 | "apiVersion": "config.openshift.io/v1", 28 | "kind": "FeatureGate", 29 | "metadata": { 30 | "name": "test-subject", 31 | "uid": "1234", 32 | "creationTimestamp": "2020-05-10T07:51:00Z", 33 | "labels": {} 34 | }, 35 | "spec": { 36 | "featureSet": "%s" 37 | } 38 | } 39 | ` 40 | 41 | func NewTestSuite(operation admissionv1.Operation, featureSet string) techpreviewnoupgradeTestSuite { 42 | return techpreviewnoupgradeTestSuite{ 43 | testID: "1234", 44 | operation: operation, 45 | featureSet: featureSet, 46 | shouldBeAllowed: true, 47 | } 48 | } 49 | 50 | func (s techpreviewnoupgradeTestSuite) ExpectNotAllowed() techpreviewnoupgradeTestSuite { 51 | s.shouldBeAllowed = false 52 | return s 53 | } 54 | 55 | func createObject(featureSet string) *runtime.RawExtension { 56 | return &runtime.RawExtension{ 57 | Raw: []byte(createRawJSONString(featureSet)), 58 | } 59 | } 60 | 61 | func createRawJSONString(featureSet string) string { 62 | s := fmt.Sprintf(testObjectRaw, featureSet) 63 | 64 | return s 65 | } 66 | 67 | func Test_AllowAnythingOtherThanTechPreviewNoUpgrade(t *testing.T) { 68 | testSuites := []techpreviewnoupgradeTestSuite{ 69 | NewTestSuite(admissionv1.Create, "AnythingOtherThanTechPreviewNoUpgrade"), 70 | NewTestSuite(admissionv1.Update, "AnythingOtherThanTechPreviewNoUpgrade"), 71 | } 72 | 73 | runTests(t, testSuites) 74 | } 75 | 76 | func Test_DoNotAllowTechPreviewNoUpgrade(t *testing.T) { 77 | testSuites := []techpreviewnoupgradeTestSuite{ 78 | NewTestSuite(admissionv1.Create, "TechPreviewNoUpgrade").ExpectNotAllowed(), 79 | NewTestSuite(admissionv1.Update, "TechPreviewNoUpgrade").ExpectNotAllowed(), 80 | } 81 | 82 | runTests(t, testSuites) 83 | } 84 | 85 | func runTests(t *testing.T, tests []techpreviewnoupgradeTestSuite) { 86 | for _, test := range tests { 87 | obj := runtime.RawExtension{ 88 | Raw: []byte(createRawJSONString(test.featureSet)), 89 | } 90 | 91 | hook := techpreviewnoupgrade.NewWebhook() 92 | httprequest, err := testutils.CreateHTTPRequest(hook.GetURI(), test.testID, metav1.GroupVersionKind{}, metav1.GroupVersionResource{}, test.operation, test.username, test.userGroups, "", &obj, nil) // we are only worried about the introduction of the featureSet 93 | 94 | if err != nil { 95 | t.Fatalf("Expected no error, got %s", err.Error()) 96 | } 97 | 98 | response, err := testutils.SendHTTPRequest(httprequest, hook) 99 | 100 | if err != nil { 101 | t.Fatalf("Expected no error, got %s", err.Error()) 102 | } 103 | 104 | if response.UID == "" { 105 | t.Fatalf("No tracking UID associated with the response: %+v", response) 106 | } 107 | 108 | if response.Allowed != test.shouldBeAllowed { 109 | 110 | t.Fatalf("Mismatch: %v %s %s. Test's expectation is that the user %s. Reason: %s, Message: %v", 111 | test, 112 | testutils.CanCanNot(response.Allowed), string(test.operation), 113 | testutils.CanCanNot(test.shouldBeAllowed), response.Result.Reason, response.Result.Message) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /pkg/webhooks/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "regexp" 9 | "slices" 10 | 11 | admissionv1 "k8s.io/api/admission/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | "k8s.io/apimachinery/pkg/runtime/serializer" 15 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 16 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 17 | ) 18 | 19 | const ( 20 | validContentType string = "application/json" 21 | // PrivilegedServiceAccountGroups is a regex string of serviceaccounts that our webhooks should commonly allow to 22 | // perform restricted actions. 23 | // Centralized osde2e tests have a serviceaccount like "system:serviceaccounts:osde2e-abcde" 24 | // Decentralized osde2e tests have a serviceaccount like "system:serviceaccounts:osde2e-h-abcde" 25 | PrivilegedServiceAccountGroups string = `^system:serviceaccounts:(kube-.*|openshift|openshift-.*|default|redhat-.*|osde2e-(h-)?[a-z0-9]{5})` 26 | ) 27 | 28 | var ( 29 | admissionScheme = runtime.NewScheme() 30 | admissionCodecs = serializer.NewCodecFactory(admissionScheme) 31 | ) 32 | 33 | func RequestMatchesGroupKind(req admissionctl.Request, kind, group string) bool { 34 | return req.Kind.Kind == kind && req.Kind.Group == group 35 | } 36 | 37 | func DefaultLabelSelector() metav1.LabelSelector { 38 | return metav1.LabelSelector{ 39 | MatchLabels: map[string]string{ 40 | "api.openshift.com/managed": "true", 41 | }, 42 | } 43 | } 44 | 45 | func IsProtectedByResourceName(name string) bool { 46 | protectedNames := []string{ 47 | "alertmanagerconfigs.monitoring.coreos.com", 48 | "alertmanagers.monitoring.coreos.com", 49 | "prometheuses.monitoring.coreos.com", 50 | "thanosrulers.monitoring.coreos.com", 51 | "podmonitors.monitoring.coreos.com", 52 | "probes.monitoring.coreos.com", 53 | "prometheusrules.monitoring.coreos.com", 54 | "servicemonitors.monitoring.coreos.com", 55 | "prometheusagents.monitoring.coreos.com", 56 | "scrapeconfigs.monitoring.coreos.com", 57 | } 58 | return slices.Contains(protectedNames, name) 59 | } 60 | 61 | func RegexSliceContains(needle string, haystack []string) bool { 62 | for _, check := range haystack { 63 | checkRe := regexp.MustCompile(check) 64 | if checkRe.Match([]byte(needle)) { 65 | return true 66 | } 67 | } 68 | return false 69 | } 70 | 71 | func ParseHTTPRequest(r *http.Request) (admissionctl.Request, admissionctl.Response, error) { 72 | var resp admissionctl.Response 73 | var req admissionctl.Request 74 | var err error 75 | var body []byte 76 | if r.Body != nil { 77 | if body, err = io.ReadAll(r.Body); err != nil { 78 | resp = admissionctl.Errored(http.StatusBadRequest, err) 79 | return req, resp, err 80 | } 81 | } else { 82 | err := errors.New("request body is nil") 83 | resp = admissionctl.Errored(http.StatusBadRequest, err) 84 | return req, resp, err 85 | } 86 | if len(body) == 0 { 87 | err := errors.New("request body is empty") 88 | resp = admissionctl.Errored(http.StatusBadRequest, err) 89 | return req, resp, err 90 | } 91 | contentType := r.Header.Get("Content-Type") 92 | if contentType != validContentType { 93 | err := fmt.Errorf("contentType=%s, expected application/json", contentType) 94 | resp = admissionctl.Errored(http.StatusBadRequest, err) 95 | return req, resp, err 96 | } 97 | ar := admissionv1.AdmissionReview{} 98 | if _, _, err := admissionCodecs.UniversalDeserializer().Decode(body, nil, &ar); err != nil { 99 | resp = admissionctl.Errored(http.StatusBadRequest, err) 100 | return req, resp, err 101 | } 102 | 103 | // Copy for tracking 104 | if ar.Request == nil { 105 | err = fmt.Errorf("No request in request body") 106 | resp = admissionctl.Errored(http.StatusBadRequest, err) 107 | return req, resp, err 108 | } 109 | resp.UID = ar.Request.UID 110 | req = admissionctl.Request{ 111 | AdmissionRequest: *ar.Request, 112 | } 113 | return req, resp, nil 114 | } 115 | 116 | // WebhookResponse assembles an allowed or denied admission response with the same UID as the provided request. 117 | // The reason for allowed admission responses is not shown to the end user and is commonly empty string: "" 118 | func WebhookResponse(request admissionctl.Request, allowed bool, reason string) admissionctl.Response { 119 | resp := admissionctl.ValidationResponse(allowed, reason) 120 | resp.UID = request.UID 121 | return resp 122 | } 123 | 124 | func init() { 125 | utilruntime.Must(admissionv1.AddToScheme(admissionScheme)) 126 | } 127 | -------------------------------------------------------------------------------- /pkg/webhooks/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | admissionv1 "k8s.io/api/admission/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 9 | ) 10 | 11 | func TestRequestMatchesGroupKind(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | req admissionctl.Request 15 | kind string 16 | group string 17 | expected bool 18 | }{ 19 | { 20 | name: "matches", 21 | req: admissionctl.Request{ 22 | AdmissionRequest: admissionv1.AdmissionRequest{ 23 | Kind: metav1.GroupVersionKind{ 24 | Kind: "testkind", 25 | Group: "testgroup", 26 | }, 27 | }, 28 | }, 29 | kind: "testkind", 30 | group: "testgroup", 31 | expected: true, 32 | }, 33 | { 34 | name: "doesn't match", 35 | req: admissionctl.Request{ 36 | AdmissionRequest: admissionv1.AdmissionRequest{ 37 | Kind: metav1.GroupVersionKind{ 38 | Kind: "testkind", 39 | Group: "testgroup", 40 | }, 41 | }, 42 | }, 43 | kind: "otherkind", 44 | group: "testgroup", 45 | expected: false, 46 | }, 47 | } 48 | 49 | for _, test := range tests { 50 | t.Run(test.name, func(t *testing.T) { 51 | actual := RequestMatchesGroupKind(test.req, test.kind, test.group) 52 | if test.expected != actual { 53 | t.Errorf("expected: %v, got %v", test.expected, actual) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/e2e/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry.ci.openshift.org/openshift/release:rhel-8-release-golang-1.23-openshift-4.19 as builder 2 | WORKDIR /go/src/github.com/thoseaunt/managed-cluster-validating-webhooks 3 | COPY . . 4 | RUN CGO_ENABLED=0 GOFLAGS="-mod=mod" go test ./test/e2e -v -c --tags=osde2e -o /harness.test 5 | 6 | FROM registry.access.redhat.com/ubi8/ubi-minimal:latest 7 | COPY --from=builder ./harness.test harness.test 8 | ENTRYPOINT [ "/harness.test" ] -------------------------------------------------------------------------------- /test/e2e/managed_cluster_validating_webhooks_runner_test.go: -------------------------------------------------------------------------------- 1 | //go:build osde2e 2 | // +build osde2e 3 | 4 | package osde2etests 5 | 6 | import ( 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | . "github.com/onsi/ginkgo/v2" 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | const ( 16 | testResultsDirectory = "/test-run-results" 17 | jUnitOutputFilename = "junit-managed-cluster-validating-webhooks.xml" 18 | ) 19 | 20 | // Test entrypoint. osde2e runs this as a test suite on test pod. 21 | func TestClusterValidatingWebhooks(t *testing.T) { 22 | RegisterFailHandler(Fail) 23 | suiteConfig, reporterConfig := GinkgoConfiguration() 24 | if _, ok := os.LookupEnv("DISABLE_JUNIT_REPORT"); !ok { 25 | reporterConfig.JUnitReport = filepath.Join(testResultsDirectory, jUnitOutputFilename) 26 | } 27 | RunSpecs(t, "Managed Cluster Validating Webhooks", suiteConfig, reporterConfig) 28 | 29 | } 30 | -------------------------------------------------------------------------------- /test/e2e/project.mk: -------------------------------------------------------------------------------- 1 | # Project specific values 2 | OPERATOR_NAME?=managed-cluster-validating-webhooks 3 | 4 | HARNESS_IMAGE_REGISTRY?=quay.io 5 | HARNESS_IMAGE_REPOSITORY?=app-sre 6 | HARNESS_IMAGE_NAME?=$(OPERATOR_NAME)-test-harness 7 | 8 | REGISTRY_USER?=$(QUAY_USER) 9 | REGISTRY_TOKEN?=$(QUAY_TOKEN) 10 | 11 | ###################### 12 | # Targets used by e2e test harness 13 | ###################### 14 | 15 | # create binary 16 | .PHONY: e2e-harness-build 17 | e2e-harness-build: GOFLAGS_MOD=-mod=mod 18 | e2e-harness-build: GOENV=GOOS=${GOOS} GOARCH=${GOARCH} CGO_ENABLED=0 GOFLAGS="${GOFLAGS_MOD}" 19 | e2e-harness-build: 20 | go mod tidy 21 | go test ./test/e2e -v -c --tags=osde2e -o harness.test 22 | 23 | # TODO: Push to a known image tag and commit id 24 | # push harness image 25 | # Use current commit as harness image tag 26 | CURRENT_COMMIT=$(shell git rev-parse --short=7 HEAD) 27 | HARNESS_IMAGE_TAG=$(CURRENT_COMMIT) 28 | 29 | .PHONY: e2e-image-build-push 30 | e2e-image-build-push: 31 | ${CONTAINER_ENGINE} build --pull -f test/e2e/Dockerfile -t $(HARNESS_IMAGE_REGISTRY)/$(HARNESS_IMAGE_REPOSITORY)/$(HARNESS_IMAGE_NAME):$(HARNESS_IMAGE_TAG) . 32 | ${CONTAINER_ENGINE} tag $(HARNESS_IMAGE_REGISTRY)/$(HARNESS_IMAGE_REPOSITORY)/$(HARNESS_IMAGE_NAME):$(HARNESS_IMAGE_TAG) $(HARNESS_IMAGE_REGISTRY)/$(HARNESS_IMAGE_REPOSITORY)/$(HARNESS_IMAGE_NAME):latest 33 | ${CONTAINER_ENGINE} push $(HARNESS_IMAGE_REGISTRY)/$(HARNESS_IMAGE_REPOSITORY)/$(HARNESS_IMAGE_NAME):$(HARNESS_IMAGE_TAG) 34 | ${CONTAINER_ENGINE} push $(HARNESS_IMAGE_REGISTRY)/$(HARNESS_IMAGE_REPOSITORY)/$(HARNESS_IMAGE_NAME):latest 35 | -------------------------------------------------------------------------------- /test/e2e/test-harness-template.yml: -------------------------------------------------------------------------------- 1 | apiVersion: template.openshift.io/v1 2 | kind: Template 3 | metadata: 4 | name: osde2e-focused-tests 5 | parameters: 6 | - name: OSDE2E_CONFIGS 7 | required: true 8 | - name: TEST_HARNESS_IMAGE 9 | required: true 10 | - name: OCM_TOKEN 11 | required: true 12 | - name: OCM_CCS 13 | required: false 14 | - name: AWS_ACCESS_KEY_ID 15 | required: false 16 | - name: AWS_SECRET_ACCESS_KEY 17 | required: false 18 | - name: CLOUD_PROVIDER_REGION 19 | required: false 20 | - name: GCP_CREDS_JSON 21 | required: false 22 | - name: JOBID 23 | generate: expression 24 | from: "[0-9a-z]{7}" 25 | - name: IMAGE_TAG 26 | value: '' 27 | required: true 28 | - name: LOG_BUCKET 29 | value: 'osde2e-logs' 30 | objects: 31 | - apiVersion: batch/v1 32 | kind: Job 33 | metadata: 34 | name: validation-webhook-${IMAGE_TAG}-${JOBID} 35 | spec: 36 | backoffLimit: 0 37 | template: 38 | spec: 39 | restartPolicy: Never 40 | containers: 41 | - name: osde2e 42 | image: quay.io/redhat-services-prod/osde2e-cicada-tenant/osde2e:latest 43 | command: 44 | - /osde2e 45 | args: 46 | - test 47 | - --only-health-check-nodes 48 | - --skip-destroy-cluster 49 | - --skip-must-gather 50 | - --configs 51 | - ${OSDE2E_CONFIGS} 52 | securityContext: 53 | runAsNonRoot: true 54 | allowPrivilegeEscalation: false 55 | capabilities: 56 | drop: ["ALL"] 57 | seccompProfile: 58 | type: RuntimeDefault 59 | env: 60 | - name: TEST_HARNESSES 61 | value: ${TEST_HARNESS_IMAGE}:${IMAGE_TAG} 62 | - name: OCM_TOKEN 63 | value: ${OCM_TOKEN} 64 | - name: OCM_CCS 65 | value: ${OCM_CCS} 66 | - name: AWS_ACCESS_KEY_ID 67 | value: ${AWS_ACCESS_KEY_ID} 68 | - name: AWS_SECRET_ACCESS_KEY 69 | value: ${AWS_SECRET_ACCESS_KEY} 70 | - name: CLOUD_PROVIDER_REGION 71 | value: ${CLOUD_PROVIDER_REGION} 72 | - name: GCP_CREDS_JSON 73 | value: ${GCP_CREDS_JSON} 74 | - name: LOG_BUCKET 75 | value: ${LOG_BUCKET} --------------------------------------------------------------------------------