Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Admission Control Webhooks #203

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions lib/bonny/admission_control.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
defmodule Bonny.AdmissionControl do
require Logger

import YamlElixir.Sigil

def ensure_tls_config(conn) do
with {:operator_name, operator_name} when is_binary(operator_name) <-
{:operator_name, System.get_env("OPERATOR_NAME")},
{:admission_config, [_ | _] = admission_configurations} <-
{:admission_config, get_admission_config(conn, operator_name)},
{:cert_bundle, {:ok, cert_bundle}} <-
{:cert_bundle, get_or_create_cert_bundle(conn, operator_name)} do
encoded_ca = Base.encode64(cert_bundle["ca.pem"])

admission_configurations
|> Enum.reject(fn config ->
Enum.all?(
List.wrap(config["webhooks"]),
&(&1["clientConfig"]["caBundle"] == encoded_ca)
)
end)
|> Enum.map(fn config ->
put_in(
config,
["webhooks", Access.all(), "clientConfig", "caBundle"],
encoded_ca
)
end)
|> Enum.each(&apply_admission_config(conn, &1))
else
{:operator_name, nil} ->
Logger.error("Env variable OPERATOR_NAME is not defined.")
:error

{:admission_config, []} ->
Logger.error("No admission configuration was found on the cluster.")
:error

{:cert_bundle, :error} ->
# System.halt(1)
:error
end
end

defp get_or_create_cert_bundle(conn, operator_name) do
with {:secret_namespace, secret_namespace} when is_binary(secret_namespace) <-
{:secret_namespace, System.get_env("SECRET_NAMESPACE")},
{:secret_name, secret_name} when is_binary(secret_name) <-
{:secret_name, System.get_env("SECRET_NAME")},
{:operator_namespace, operator_namespace} when is_binary(operator_namespace) <-
{:operator_namespace, System.get_env("OPERATOR_NAMESPACE")},
{:secret, _, _, _, _, {:ok, secret}} <-
{:secret, operator_namespace, operator_name, secret_namespace, secret_name,
get_secret(conn, secret_namespace, secret_name)},
{:cert_bundle,
%{"key.pem" => _key, "cert.pem" => _cert, "ca_key.pem" => _ca_key, "ca.pem" => _ca} =
cert_bundle} <-
{:cert_bundle, decode_secret(secret)} do
{:ok, cert_bundle}
else
{:secret_namespace, nil} ->
Logger.error("Env variable SECRET_NAMESPACE is not defined.")
:error

{:secret_name, nil} ->
Logger.error("Env variable SECRET_NAME is not defined.")
:error

{:operator_namespace, nil} ->
Logger.error("Env variable OPERATOR_NAMESPACE is not defined.")
:error

{:secret, operator_namespace, operator_name, secret_namespace, secret_name,
{:error, %K8s.Client.APIError{reason: "NotFound"}}} ->
Logger.info("Secret with certificate bundle was not found. Attempting to create it.")

create_cert_bundle_and_secret(
conn,
operator_namespace,
operator_name,
secret_namespace,
secret_name
)

{:secret, _, _, _, _, {:error, exception}}
when is_exception(exception) ->
Logger.error("Can't get secret with certificate bundle: #{Exception.message(exception)}")
:error

{:secret, _, _, _, _, {:error, _}} ->
Logger.error("Can't get secret with certificate bundle.")
:error

{:cert_bundle, _} ->
Logger.error("Certificate secret exists but has the wrong shape.")
:error
end
end

defp get_secret(conn, namespace, name) do
K8s.Client.get("v1", "secret", name: name, namespace: namespace)
|> K8s.Client.put_conn(conn)
|> K8s.Client.run()
end

defp decode_secret(secret) do
Map.new(secret["data"], fn {key, value} -> {key, Base.decode64!(value)} end)
end

defp create_cert_bundle_and_secret(
conn,
operator_namespace,
operator_name,
secret_namespace,
secret_name
) do
ca_key = X509.PrivateKey.new_ec(:secp256r1)

ca =
X509.Certificate.self_signed(
ca_key,
"/C=CH/ST=ZH/L=Zurich/O=Bonny/CN=Bonny Root CA",
template: :root_ca
)

key = X509.PrivateKey.new_ec(:secp256r1)

cert =
key
|> X509.PublicKey.derive()
|> X509.Certificate.new(
"/C=CH/ST=ZH/L=Zurich/O=Bonny/CN=Bonny Admission Control Cert",
ca,
ca_key,
extensions: [
subject_alt_name:
X509.Certificate.Extension.subject_alt_name([
"#{operator_name}-webhook-service",
"#{operator_name}-webhook-service.#{operator_namespace}",
"#{operator_name}-webhook-service.#{operator_namespace}.svc"
])
]
)

cert_bundle = %{
"key.pem" => X509.PrivateKey.to_pem(key),
"cert.pem" => X509.Certificate.to_pem(cert),
"ca_key.pem" => X509.PrivateKey.to_pem(ca_key),
"ca.pem" => X509.Certificate.to_pem(ca)
}

case create_secret(conn, secret_namespace, secret_name, cert_bundle) do
{:ok, _} ->
{:ok, cert_bundle}

{:error, %K8s.Client.APIError{reason: "AlreadyExists"}} ->
# Looks like another pod was faster. Let's just start over:
get_or_create_cert_bundle(conn, operator_name)

{:error, exception} when is_exception(exception) ->
raise "Secret creation failed: #{Exception.message(exception)}"

{:error, _} ->
raise "Secret creation failed."
end
end

defp create_secret(conn, namespace, name, data) do
~y"""
apiVersion: v1
kind: Secret
metadata:
name: #{name}
namespace: #{namespace}
"""
|> Map.put("stringData", data)
|> K8s.Client.create()
|> K8s.Client.put_conn(conn)
|> K8s.Client.run()
end

defp get_admission_config(conn, operator_name) do
validating_webhook_config =
K8s.Client.get("admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration",
name: "#{operator_name}"
)
|> K8s.Client.put_conn(conn)
|> K8s.Client.run()

mutating_webhook_config =
K8s.Client.get("admissionregistration.k8s.io/v1", "MutatingWebhookConfiguration",
name: "#{operator_name}"
)
|> K8s.Client.put_conn(conn)
|> K8s.Client.run()

[validating_webhook_config, mutating_webhook_config]
|> Enum.filter(&match?({:ok, _}, &1))
|> Enum.map(&elem(&1, 1))
end

defp apply_admission_config(conn, admission_config) do
result =
admission_config
|> Bonny.Resource.drop_managed_fields()
|> Bonny.Resource.apply(conn, [])

case result do
{:ok, _} -> :ok
{:error, _} -> raise "Could not patch admission config"
end
end
end
153 changes: 153 additions & 0 deletions lib/bonny/admission_control/admission_review.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
defmodule Bonny.AdmissionControl.AdmissionReview do
@moduledoc """
Internal representation of an admission review.
"""

require Logger

@derive Pluggable.Token

@type webhook_type :: :mutating | :validating
@type t :: %__MODULE__{
request: map(),
response: map(),
webhook_type: webhook_type(),
halted: boolean(),
assigns: map()
}

@enforce_keys [:request, :response, :webhook_type]
defstruct [:request, :response, :webhook_type, halted: false, assigns: %{}]

def new(%{"kind" => "AdmissionReview", "request" => request}, webhook_type) do
struct!(__MODULE__,
request: request,
response: %{"uid" => request["uid"]},
webhook_type: webhook_type
)
end

@doc """
Responds by allowing the operation

## Examples

iex> admission_review = %Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{}, webhook_type: :validating}
...> Bonny.AdmissionControl.AdmissionReview.allow(admission_review)
%Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{"allowed" => true}, webhook_type: :validating}
"""
@spec allow(t()) :: t()
def allow(admission_review) do
put_in(admission_review.response["allowed"], true)
end

@doc """
Responds by denying the operation

## Examples

iex> admission_review = %Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{}, webhook_type: :validating}
...> Bonny.AdmissionControl.AdmissionReview.deny(admission_review)
%Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{"allowed" => false}, webhook_type: :validating}
"""
@spec deny(t()) :: t()
def deny(admission_review) do
put_in(admission_review.response["allowed"], false)
end

@doc """
Responds by denying the operation, returning response code and message

## Examples

iex> admission_review = %Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{}, webhook_type: :validating}
...> Bonny.AdmissionControl.AdmissionReview.deny(admission_review, 403, "foo")
%Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{"allowed" => false, "status" => %{"code" => 403, "message" => "foo"}}, webhook_type: :validating}

iex> Bonny.AdmissionControl.AdmissionReview.deny(%Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{}, webhook_type: :validating}, "foo")
%Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{"allowed" => false, "status" => %{"code" => 400, "message" => "foo"}}, webhook_type: :validating}
"""
@spec deny(t(), integer(), binary()) :: t()
@spec deny(t(), binary()) :: t()
def deny(admission_review, code \\ 400, message) do
admission_review
|> deny()
|> put_in([Access.key(:response), "status"], %{"code" => code, "message" => message})
end

@doc """
Adds a warning to the admission review's response.

## Examples

iex> admission_review = %Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{}, webhook_type: :validating}
...> Bonny.AdmissionControl.AdmissionReview.add_warning(admission_review, "warning")
%Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{"warnings" => ["warning"]}, webhook_type: :validating}

iex> admission_review = %Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{"warnings" => ["existing_warning"]}, webhook_type: :validating}
...> Bonny.AdmissionControl.AdmissionReview.add_warning(admission_review, "new_warning")
%Bonny.AdmissionControl.AdmissionReview{request: %{}, response: %{"warnings" => ["new_warning", "existing_warning"]}, webhook_type: :validating}
"""
@spec add_warning(t(), binary()) :: t()
def add_warning(admission_review, warning) do
update_in(
admission_review,
[Access.key(:response), Access.key("warnings", [])],
&[warning | &1]
)
end

@doc """
Verifies that a given field has not been mutated.

## Examples

iex> admission_review = %Bonny.AdmissionControl.AdmissionReview{request: %{"object" => %{"spec" => %{"immutable" => "value"}}, "oldObject" => %{"spec" => %{"immutable" => "value"}}}, response: %{}, webhook_type: :validating}
...> Bonny.AdmissionControl.AdmissionReview.check_immutable(admission_review, ["spec", "immutable"])
%Bonny.AdmissionControl.AdmissionReview{request: %{"object" => %{"spec" => %{"immutable" => "value"}}, "oldObject" => %{"spec" => %{"immutable" => "value"}}}, response: %{}, webhook_type: :validating}

iex> admission_review = %Bonny.AdmissionControl.AdmissionReview{request: %{"object" => %{"spec" => %{"immutable" => "new_value"}}, "oldObject" => %{"spec" => %{"immutable" => "value"}}}, response: %{}, webhook_type: :validating}
...> Bonny.AdmissionControl.AdmissionReview.check_immutable(admission_review, ["spec", "immutable"])
%Bonny.AdmissionControl.AdmissionReview{request: %{"object" => %{"spec" => %{"immutable" => "new_value"}}, "oldObject" => %{"spec" => %{"immutable" => "value"}}}, response: %{"allowed" => false, "status" => %{"code" => 400, "message" => "The field .spec.immutable is immutable."}}, webhook_type: :validating}
"""
@spec check_immutable(t(), list()) :: t()
def check_immutable(admission_review, field) do
new_value = get_in(admission_review.request, ["object" | field])
old_value = get_in(admission_review.request, ["oldObject" | field])

if new_value == old_value,
do: admission_review,
else: deny(admission_review, "The field .#{Enum.join(field, ".")} is immutable.")
end

@doc """
Checks the given field's value - if defined - against a list of allowed values. If the field is not defined, the
request is considered valid and no error is returned. Use the CRD to define required fields.

## Examples

iex> admission_review = %Bonny.AdmissionControl.AdmissionReview{request: %{"object" => %{"metadata" => %{"annotations" => %{"some/annotation" => "bar"}}, "spec" => %{}}, "oldObject" => %{"spec" => %{}}}, response: %{}, webhook_type: :validating}
...> Bonny.AdmissionControl.AdmissionReview.check_allowed_values(admission_review, ~w(metadata annotations some/annotation), ["foo", "bar"])
%Bonny.AdmissionControl.AdmissionReview{request: %{"object" => %{"metadata" => %{"annotations" => %{"some/annotation" => "bar"}}, "spec" => %{}}, "oldObject" => %{"spec" => %{}}}, response: %{}, webhook_type: :validating}

iex> admission_review = %Bonny.AdmissionControl.AdmissionReview{request: %{"object" => %{"metadata" => %{}, "spec" => %{}}, "oldObject" => %{"spec" => %{}}}, response: %{}, webhook_type: :validating}
...> Bonny.AdmissionControl.AdmissionReview.check_allowed_values(admission_review, ~w(metadata annotations some/annotation), ["foo", "bar"])
%Bonny.AdmissionControl.AdmissionReview{request: %{"object" => %{"metadata" => %{}, "spec" => %{}}, "oldObject" => %{"spec" => %{}}}, response: %{}, webhook_type: :validating}

iex> admission_review = %Bonny.AdmissionControl.AdmissionReview{request: %{"object" => %{"metadata" => %{"annotations" => %{"some/annotation" => "other"}}, "spec" => %{}}, "oldObject" => %{"spec" => %{}}}, response: %{}, webhook_type: :validating}
...> Bonny.AdmissionControl.AdmissionReview.check_allowed_values(admission_review, ~w(metadata annotations some/annotation), ["foo", "bar"])
%Bonny.AdmissionControl.AdmissionReview{request: %{"object" => %{"metadata" => %{"annotations" => %{"some/annotation" => "other"}}, "spec" => %{}}, "oldObject" => %{"spec" => %{}}}, response: %{"allowed" => false, "status" => %{"code" => 400, "message" => ~S(The field .metadata.annotations.some/annotation must contain one of the values in ["foo", "bar"] but it's currently set to "other".)}}, webhook_type: :validating}
"""
@spec check_allowed_values(t(), list(), list()) :: t()
def check_allowed_values(admission_review, field, allowed_values) do
value = get_in(admission_review.request, ["object" | field])

if is_nil(value) or value in allowed_values,
do: admission_review,
else:
deny(
admission_review,
"The field .metadata.annotations.some/annotation must contain one of the values in #{inspect(allowed_values)} but it's currently set to #{inspect(value)}."
)
end
end
Loading