This tutorial shows how to deploy OPA as an admission controller from scratch. It covers the OPA-kubernetes version that uses kube-mgmt. The OPA Gatekeeper version has its own docs. For the purpose of the tutorial we will deploy two policies that ensure:

  • Ingress hostnames must be on allowlist on the Namespace containing the Ingress.
  • Two ingresses in different namespaces must not have the same hostname.

💡 Kubernetes does not guarantee consistency across resources. If two ingresses are created in parallel, there is no guarantee that OPA (or any other admission controller) will observe the creation of one ingress before the other. This means that it’s not possible to enforce these policies during admission control 100% of the time. There will be a small window of time (usually on the order of milliseconds) when the eventually consistent cache inside of OPA (or any other admission controller) is out-of-date. To catch these violations we recommend you periodically audit the state of the cluster against your policies. Offline auditing is one of the features provided by the OPA Gatekeeper project.

Prerequisites

This tutorial requires Kubernetes 1.20 or later. To run the tutorial locally ensure you start a cluster with Kubernetes version 1.20+, we recommend using minikube or KIND.

Steps

To implement admission control rules that validate Kubernetes resources during create, update, and delete operations, you must enable the ValidatingAdmissionWebhook when the Kubernetes API server is started. The ValidatingAdmissionWebhook admission controller is included in the recommended set of admission controllers to enable

Start minikube:

  1. minikube start

Make sure that the minikube ingress addon is enabled:

  1. minikube addons enable ingress

2. Create a new Namespace to deploy OPA into

  1. kubectl create namespace opa

Configure kubectl to use this namespace:

  1. kubectl config set-context opa-tutorial --user minikube --cluster minikube --namespace opa
  2. kubectl config use-context opa-tutorial

3. Create TLS credentials for OPA

Communication between Kubernetes and OPA must be secured using TLS. To configure TLS, use openssl to create a certificate authority (CA) and certificate/key pair for OPA:

  1. openssl genrsa -out ca.key 2048
  2. openssl req -x509 -new -nodes -key ca.key -days 100000 -out ca.crt -subj "/CN=admission_ca"

Generate the TLS key and certificate for OPA:

  1. cat >server.conf <<EOF
  2. [req]
  3. req_extensions = v3_req
  4. distinguished_name = req_distinguished_name
  5. prompt = no
  6. [req_distinguished_name]
  7. CN = opa.opa.svc
  8. [ v3_req ]
  9. basicConstraints = CA:FALSE
  10. keyUsage = nonRepudiation, digitalSignature, keyEncipherment
  11. extendedKeyUsage = clientAuth, serverAuth
  12. subjectAltName = @alt_names
  13. [alt_names]
  14. DNS.1 = opa.opa.svc
  15. EOF
  1. openssl genrsa -out server.key 2048
  2. openssl req -new -key server.key -out server.csr -config server.conf
  3. openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 100000 -extensions v3_req -extfile server.conf

Note: the Common Name value and Subject Alternative Name you give to openssl MUST match the name of the OPA service created below.

Create a Secret to store the TLS credentials for OPA:

  1. kubectl create secret tls opa-server --cert=server.crt --key=server.key --namespace opa

4. Define OPA policy

Let’s define a couple of policies to test admission control. First create a new folder to store our policies:

  1. mkdir policies && cd policies

Policy 1: Restrict Hostnames

Create a policy that restricts the hostnames that an ingress can use. Only hostnames matching the specified regular expressions will be allowed.

ingress-allowlist.rego:

  1. package kubernetes.admission
  2. import data.kubernetes.namespaces
  3. operations := {"CREATE", "UPDATE"}
  4. deny[msg] {
  5. input.request.kind.kind == "Ingress"
  6. operations[input.request.operation]
  7. host := input.request.object.spec.rules[_].host
  8. not fqdn_matches_any(host, valid_ingress_hosts)
  9. msg := sprintf("invalid ingress host %q", [host])
  10. }
  11. valid_ingress_hosts := {host |
  12. allowlist := namespaces[input.request.namespace].metadata.annotations["ingress-allowlist"]
  13. hosts := split(allowlist, ",")
  14. host := hosts[_]
  15. }
  16. fqdn_matches_any(str, patterns) {
  17. fqdn_matches(str, patterns[_])
  18. }
  19. fqdn_matches(str, pattern) {
  20. pattern_parts := split(pattern, ".")
  21. pattern_parts[0] == "*"
  22. str_parts := split(str, ".")
  23. n_pattern_parts := count(pattern_parts)
  24. n_str_parts := count(str_parts)
  25. suffix := trim(pattern, "*.")
  26. endswith(str, suffix)
  27. }
  28. fqdn_matches(str, pattern) {
  29. not contains(pattern, "*")
  30. str == pattern
  31. }

Policy 2: Prohibit Hostname Conflicts

Now let’s define another policy to test admission control. The following policy prevents Ingress objects in different namespaces from sharing the same hostname.

ingress-conflicts.rego:

  1. package kubernetes.admission
  2. import data.kubernetes.ingresses
  3. deny[msg] {
  4. some other_ns, other_ingress
  5. input.request.kind.kind == "Ingress"
  6. input.request.operation == "CREATE"
  7. host := input.request.object.spec.rules[_].host
  8. ingress := ingresses[other_ns][other_ingress]
  9. other_ns != input.request.namespace
  10. ingress.spec.rules[_].host == host
  11. msg := sprintf("invalid ingress host %q (conflicts with %v/%v)", [host, other_ns, other_ingress])
  12. }

Combine Policies

Let’s define a main policy that imports the Restrict Hostnames and Prohibit Hostname Conflicts policies and provides an overall policy decision.

main.rego:

  1. package system
  2. import data.kubernetes.admission
  3. main := {
  4. "apiVersion": "admission.k8s.io/v1",
  5. "kind": "AdmissionReview",
  6. "response": response,
  7. }
  8. default uid := ""
  9. uid := input.request.uid
  10. response := {
  11. "allowed": false,
  12. "uid": uid,
  13. "status": {
  14. "message": reason,
  15. },
  16. } {
  17. reason = concat(", ", admission.deny)
  18. reason != ""
  19. }
  20. else := {"allowed": true, "uid": uid}

⚠️ When OPA receives a request, it executes a query against the document defined data.system.main by default.

5. Build and Publish OPA Bundle

Build an OPA bundle containing policies defined in the previous step. In our setup, OPA will download policies from the bundle service and the kube-mgmt container will load Kubernetes resources into OPA. Since we load policy and data into OPA from multiple sources, we need to scope the bundle to a subset of OPA’s policy and data cache by defining a manifest. More information about this can be found here. Run the following commands in the policies folder created in the previous step.

  1. cat > .manifest <<EOF
  2. {
  3. "roots": ["kubernetes/admission", "system"]
  4. }
  5. EOF
  1. opa build -b .

We will now serve the OPA bundle using Nginx.

  1. docker run --rm --name bundle-server -d -p 8888:80 -v ${PWD}:/usr/share/nginx/html:ro nginx:latest

6. Deploy OPA as an Admission Controller

Next, use the file below to deploy OPA as an admission controller.

admission-controller.yaml:

  1. # Grant OPA/kube-mgmt read-only access to resources. This lets kube-mgmt
  2. # replicate resources into OPA so they can be used in policies.
  3. kind: ClusterRoleBinding
  4. apiVersion: rbac.authorization.k8s.io/v1
  5. metadata:
  6. name: opa-viewer
  7. roleRef:
  8. kind: ClusterRole
  9. name: view
  10. apiGroup: rbac.authorization.k8s.io
  11. subjects:
  12. - kind: Group
  13. name: system:serviceaccounts:opa
  14. apiGroup: rbac.authorization.k8s.io
  15. ---
  16. # Define role for OPA/kube-mgmt to update configmaps with policy status.
  17. kind: Role
  18. apiVersion: rbac.authorization.k8s.io/v1
  19. metadata:
  20. namespace: opa
  21. name: configmap-modifier
  22. rules:
  23. - apiGroups: [""]
  24. resources: ["configmaps"]
  25. verbs: ["update", "patch"]
  26. ---
  27. # Grant OPA/kube-mgmt role defined above.
  28. kind: RoleBinding
  29. apiVersion: rbac.authorization.k8s.io/v1
  30. metadata:
  31. namespace: opa
  32. name: opa-configmap-modifier
  33. roleRef:
  34. kind: Role
  35. name: configmap-modifier
  36. apiGroup: rbac.authorization.k8s.io
  37. subjects:
  38. - kind: Group
  39. name: system:serviceaccounts:opa
  40. apiGroup: rbac.authorization.k8s.io
  41. ---
  42. kind: Service
  43. apiVersion: v1
  44. metadata:
  45. name: opa
  46. namespace: opa
  47. spec:
  48. selector:
  49. app: opa
  50. ports:
  51. - name: https
  52. protocol: TCP
  53. port: 443
  54. targetPort: 8443
  55. ---
  56. apiVersion: apps/v1
  57. kind: Deployment
  58. metadata:
  59. labels:
  60. app: opa
  61. namespace: opa
  62. name: opa
  63. spec:
  64. replicas: 1
  65. selector:
  66. matchLabels:
  67. app: opa
  68. template:
  69. metadata:
  70. labels:
  71. app: opa
  72. name: opa
  73. spec:
  74. containers:
  75. # WARNING: OPA is NOT running with an authorization policy configured. This
  76. # means that clients can read and write policies in OPA. If you are
  77. # deploying OPA in an insecure environment, be sure to configure
  78. # authentication and authorization on the daemon. See the Security page for
  79. # details: https://www.openpolicyagent.org/docs/security.html.
  80. - name: opa
  81. image: openpolicyagent/opa:0.40.0-rootless
  82. args:
  83. - "run"
  84. - "--server"
  85. - "--tls-cert-file=/certs/tls.crt"
  86. - "--tls-private-key-file=/certs/tls.key"
  87. - "--addr=0.0.0.0:8443"
  88. - "--addr=http://127.0.0.1:8181"
  89. - "--set=services.default.url=http://host.minikube.internal:8888"
  90. - "--set=bundles.default.resource=bundle.tar.gz"
  91. - "--log-format=json-pretty"
  92. - "--set=status.console=true"
  93. - "--set=decision_logs.console=true"
  94. volumeMounts:
  95. - readOnly: true
  96. mountPath: /certs
  97. name: opa-server
  98. readinessProbe:
  99. httpGet:
  100. path: /health?plugins&bundle
  101. scheme: HTTPS
  102. port: 8443
  103. initialDelaySeconds: 3
  104. periodSeconds: 5
  105. livenessProbe:
  106. httpGet:
  107. path: /health
  108. scheme: HTTPS
  109. port: 8443
  110. initialDelaySeconds: 3
  111. periodSeconds: 5
  112. - name: kube-mgmt
  113. image: openpolicyagent/kube-mgmt:2.0.1
  114. args:
  115. - "--replicate-cluster=v1/namespaces"
  116. - "--replicate=networking.k8s.io/v1/ingresses"
  117. volumes:
  118. - name: opa-server
  119. secret:
  120. secretName: opa-server

⚠️ If using kind to run a local Kubernetes cluster, the bundle service URL should be http://host.docker.internal:8888.

  1. kubectl apply -f admission-controller.yaml

When OPA starts, the kube-mgmt container will load Kubernetes Namespace and Ingress objects into OPA. You can configure the sidecar to load any kind of Kubernetes object into OPA. The sidecar establishes watches on the Kubernetes API server so that OPA has access to an eventually consistent cache of Kubernetes objects.

Next, generate the manifest that will be used to register OPA as an admission controller. This webhook will ignore any namespace with the label openpolicyagent.org/webhook=ignore.

  1. cat > webhook-configuration.yaml <<EOF
  2. kind: ValidatingWebhookConfiguration
  3. apiVersion: admissionregistration.k8s.io/v1
  4. metadata:
  5. name: opa-validating-webhook
  6. webhooks:
  7. - name: validating-webhook.openpolicyagent.org
  8. namespaceSelector:
  9. matchExpressions:
  10. - key: openpolicyagent.org/webhook
  11. operator: NotIn
  12. values:
  13. - ignore
  14. rules:
  15. - operations: ["CREATE", "UPDATE"]
  16. apiGroups: ["*"]
  17. apiVersions: ["*"]
  18. resources: ["*"]
  19. clientConfig:
  20. caBundle: $(cat ca.crt | base64 | tr -d '\n')
  21. service:
  22. namespace: opa
  23. name: opa
  24. admissionReviewVersions: ["v1"]
  25. sideEffects: None
  26. EOF

The generated configuration file includes a base64 encoded representation of the CA certificate created in Step 3 so that TLS connections can be established between the Kubernetes API server and OPA.

Next label kube-system and the opa namespace so that OPA does not control the resources in those namespaces.

  1. kubectl label ns kube-system openpolicyagent.org/webhook=ignore
  2. kubectl label ns opa openpolicyagent.org/webhook=ignore

Finally, register OPA as an admission controller:

  1. kubectl apply -f webhook-configuration.yaml

You can follow the OPA logs to see the webhook requests being issued by the Kubernetes API server:

  1. # ctrl-c to exit
  2. kubectl logs -l app=opa -c opa -f

7. Exercise Restrict Hostnames policy

Now let’s exercise the Restrict Hostnames policy by creating two new namespaces.

qa-namespace.yaml:

  1. apiVersion: v1
  2. kind: Namespace
  3. metadata:
  4. annotations:
  5. ingress-allowlist: "*.qa.acmecorp.com,*.internal.acmecorp.com"
  6. name: qa

production-namespace.yaml:

  1. apiVersion: v1
  2. kind: Namespace
  3. metadata:
  4. annotations:
  5. ingress-allowlist: "*.acmecorp.com"
  6. name: production
  1. kubectl create -f qa-namespace.yaml
  2. kubectl create -f production-namespace.yaml

Next, define two Ingress objects. One of the Ingress objects will be permitted and the other will be rejected.

ingress-ok.yaml:

  1. apiVersion: networking.k8s.io/v1
  2. kind: Ingress
  3. metadata:
  4. name: ingress-ok
  5. spec:
  6. rules:
  7. - host: signin.acmecorp.com
  8. http:
  9. paths:
  10. - pathType: ImplementationSpecific
  11. path: /
  12. backend:
  13. service:
  14. name: nginx
  15. port:
  16. number: 80

ingress-bad.yaml:

  1. apiVersion: networking.k8s.io/v1
  2. kind: Ingress
  3. metadata:
  4. name: ingress-bad
  5. spec:
  6. rules:
  7. - host: acmecorp.com
  8. http:
  9. paths:
  10. - pathType: ImplementationSpecific
  11. path: /
  12. backend:
  13. service:
  14. name: nginx
  15. port:
  16. number: 80

Finally, try to create both Ingress objects:

  1. kubectl create -f ingress-ok.yaml -n production
  2. kubectl create -f ingress-bad.yaml -n qa

The second Ingress is rejected because its hostname does not match the allowlist in the qa namespace.

It will report an error as follows:

  1. Error from server: error when creating "ingress-bad.yaml": admission webhook "validating-webhook.openpolicyagent.org"
  2. denied the request: invalid ingress host "acmecorp.com"

8. Exercise Prohibit Hostname Conflicts policy

Test the Prohibit Hostname Conflicts policy by verifying that you cannot create an Ingress in another namespace with the same hostname as the one created earlier.

staging-namespace.yaml:

  1. apiVersion: v1
  2. kind: Namespace
  3. metadata:
  4. annotations:
  5. ingress-allowlist: "*.acmecorp.com"
  6. name: staging
  1. kubectl create -f staging-namespace.yaml
  1. kubectl create -f ingress-ok.yaml -n staging

The above command will report an error as follows:

  1. Error from server (BadRequest): error when creating "ingress-ok.yaml": admission webhook
  2. "validate.nginx.ingress.kubernetes.io" denied the request: host "signin.acmecorp.com" and
  3. path "/" is already defined in ingress production/ingress-ok

Wrap Up

Congratulations for finishing the tutorial!

This tutorial showed how you can leverage OPA to enforce admission control decisions in Kubernetes clusters without modifying or recompiling any Kubernetes components. Furthermore, with OPA’s Bundle feature policies can be periodically downloaded from remote servers to satisfy changing operational requirements.

For more information about deploying OPA on top of Kubernetes, see Deployments - Kubernetes.