From c39273f87661b49114d2ee056901fe899974ce5e Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 24 Feb 2022 18:45:42 +0100 Subject: [PATCH 01/22] added support for authentication in the alert (both basic and bearer). --- connaisseur/alert.py | 171 +++++++++++++++++++++++- connaisseur/res/alertconfig_schema.json | 148 +++++++++++++++++++- 2 files changed, 316 insertions(+), 3 deletions(-) diff --git a/connaisseur/alert.py b/connaisseur/alert.py index a8fdef4bf..f73117018 100644 --- a/connaisseur/alert.py +++ b/connaisseur/alert.py @@ -48,6 +48,166 @@ def alerting_required(self, event_category: str) -> bool: return bool(self.config.get(event_category)) +class AlertReceiverAuthentication: + """ + Class to store authentication information for securely sending events to the alert receiver. + """ + + class AlertReceiverBasicAuthentication: + """ + Class to store authentication information for basic authentication type with username and password. + """ + username: str + password: str + authentication_type: str + authorization_prefix: str = "Basic" + + def __init__(self, alert_receiver_config: dict): + basic_authentication_config = alert_receiver_config.get( + "receiver_authentication_basic", None + ) + + if ( + basic_authentication_config is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError("No basic authentication configuration found.") + + username_env = basic_authentication_config.get("username_env", None) + password_env = basic_authentication_config.get("password_env", None) + + if ( + username_env is None or password_env is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "No username_env or password_env configuration found." + ) + + self.username = os.environ.get(username_env, None) + self.password = os.environ.get(password_env, None) + + if self.username is None or self.password is None: + raise ConfigurationError( + f"No username or password found from environmental variables {username_env} and {password_env}." + ) + + self.authorization_prefix = basic_authentication_config.get( + "authorization_prefix", "Basic" + ) + # TODO maybe validate authorization prefix + + def get_header(self) -> dict: + return { + "Authorization": f"{self.authorization_prefix} {self.username}:{self.password}" + } + + class AlertReceiverBearerAuthentication: + """ + Class to store authentication information for bearer authentication type which uses a token. + """ + token: str + authorization_prefix: str = "Bearer" # default is bearer + + def __init__(self, alert_receiver_config: dict): + bearer_authentication_config = alert_receiver_config.get( + "receiver_authentication_bearer", None + ) + + if ( + bearer_authentication_config is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "No bearer authentication configuration found." + ) + + token_env = bearer_authentication_config.get("token_env", None) + token_file = bearer_authentication_config.get("token_file", None) + + if ( + token_env is None and token_file is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "No token_env and token_file configuration found." + ) + + if ( + token_env is not None and token_file is not None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "Both token_env and token_file configuration found. Only one is required." + ) + + if token_env is not None: + self.token = os.environ.get(token_env, None) + + if self.token is None: + raise ConfigurationError( + f"No token found from environmental variable {token_env}." + ) + else: + try: + with open(token_file, "r") as token_file: + self.token = token_file.read() + except FileNotFoundError: + raise ConfigurationError(f"No token file found at {token_file}.") + except Exception as err: + raise ConfigurationError( + f"An error occurred while loading the token file {token_file}: {str(err)}" + ) + + self.authorization_prefix = bearer_authentication_config.get( + "authorization_prefix", "Bearer" + ) + # TODO maybe validate authorization prefix + + def get_header(self) -> dict: + return {"Authorization": f"{self.authorization_prefix} {self.token}"} + + def __init__(self, alert_receiver_config: dict): + self.authentication_type = alert_receiver_config.get( + "receiver_authentication_type", "none" + ) + + if self.is_basic(): + self.__init_basic_authentication(alert_receiver_config) + elif self.is_bearer(): + self.__init_bearer_authentication(alert_receiver_config) + + def is_basic(self): + return self.authentication_type == "basic" + + def is_bearer(self): + return self.authentication_type == "bearer" + + def is_none(self): + return self.authentication_type == "none" + + def __init_bearer_authentication(self, alert_receiver_config: dict): + self.bearer_authentication = ( + AlertReceiverAuthentication.AlertReceiverBearerAuthentication( + alert_receiver_config + ) + ) + + def __init_basic_authentication(self, alert_receiver_config: dict): + self.basic_authentication = ( + AlertReceiverAuthentication.AlertReceiverBasicAuthentication( + alert_receiver_config + ) + ) + + def get_auth_header(self) -> dict: + if self.is_basic(): + return self.basic_authentication.get_header() + elif self.is_bearer(): + return self.bearer_authentication.get_header() + elif self.is_none(): + return {} + else: + raise ConfigurationError( + "No authentication type found." + ) # hopefully this never happens + + class Alert: """ Class to store image information about an alert as attributes and a sending @@ -59,6 +219,7 @@ class Alert: template: str receiver_url: str + receiver_authentication: AlertReceiverAuthentication payload: str headers: dict @@ -101,12 +262,13 @@ def __init__( "images": images, } self.receiver_url = receiver_config["receiver_url"] + self.receiver_authentication = AlertReceiverAuthentication(receiver_config) self.template = receiver_config["template"] self.throw_if_alert_sending_fails = receiver_config.get( "fail_if_alert_sending_fails", False ) self.payload = self.__construct_payload(receiver_config) - self.headers = self.__get_headers(receiver_config) + self.headers = self.__get_headers(receiver_config, self.receiver_authentication) def __construct_payload(self, receiver_config: dict) -> str: try: @@ -153,13 +315,18 @@ def send_alert(self) -> Optional[requests.Response]: return response @staticmethod - def __get_headers(receiver_config): + def __get_headers( + receiver_config: dict, receiver_authentication: AlertReceiverAuthentication + ) -> dict: headers = {"Content-Type": "application/json"} additional_headers = receiver_config.get("custom_headers") if additional_headers is not None: for header in additional_headers: key, value = header.split(":", 1) headers.update({key.strip(): value.strip()}) + auth_header = receiver_authentication.get_auth_header() + if auth_header: # not None and not empty + headers.update(auth_header) return headers diff --git a/connaisseur/res/alertconfig_schema.json b/connaisseur/res/alertconfig_schema.json index 8a894b2b1..72d405d34 100644 --- a/connaisseur/res/alertconfig_schema.json +++ b/connaisseur/res/alertconfig_schema.json @@ -18,6 +18,50 @@ "receiver_url": { "type": "string" }, + "receiver_authentication_type": { + "type": "string", + "enum": [ + "none", + "basic", + "bearer" + ] + }, + "receiver_authentication_basic": { + "type": "object", + "properties": { + "username_env": { + "type": "string" + }, + "password_env": { + "type": "string" + }, + "authorization_prefix": { + "type": "string" + } + }, + "required": [ + "username_env", + "password_env" + ] + }, + "receiver_authentication_bearer": { + "type": "object", + "oneOf": [ + {"required": ["token_file"]}, + {"required": ["token_env"]} + ], + "properties": { + "authorization_prefix": { + "type": "string" + }, + "token_file": { + "type": "string" + }, + "token_env": { + "type": "string" + } + } + }, "priority": { "type": "integer" }, @@ -35,6 +79,35 @@ "type": "boolean" } }, + "anyOf": [ + { + "properties": { + "receiver_authentication_type": { + "const": "basic" + } + }, + "required": [ + "receiver_authentication_basic" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "bearer" + } + }, + "required": [ + "receiver_authentication_bearer" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "none" + } + } + } + ], "required": [ "template", "receiver_url" @@ -60,6 +133,50 @@ "receiver_url": { "type": "string" }, + "receiver_authentication_type": { + "type": "string", + "enum": [ + "none", + "basic", + "bearer" + ] + }, + "receiver_authentication_basic": { + "type": "object", + "properties": { + "username_env": { + "type": "string" + }, + "password_env": { + "type": "string" + }, + "authorization_prefix": { + "type": "string" + } + }, + "required": [ + "username_env", + "password_env" + ] + }, + "receiver_authentication_bearer": { + "type": "object", + "oneOf": [ + {"required": ["token_file"]}, + {"required": ["token_env"]} + ], + "properties": { + "authorization_prefix": { + "type": "string" + }, + "token_file": { + "type": "string" + }, + "token_env": { + "type": "string" + } + } + }, "priority": { "type": "integer" }, @@ -77,6 +194,35 @@ "type": "boolean" } }, + "anyOf": [ + { + "properties": { + "receiver_authentication_type": { + "const": "basic" + } + }, + "required": [ + "receiver_authentication_basic" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "bearer" + } + }, + "required": [ + "receiver_authentication_bearer" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "none" + } + } + } + ], "required": [ "template", "receiver_url" @@ -89,4 +235,4 @@ ] } } -} +} \ No newline at end of file From 1fa06930fd5ebfb4705d1d0ce6404bdff51d8786 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 24 Feb 2022 18:47:26 +0100 Subject: [PATCH 02/22] added unit tests for alert authentication --- .../alertconfig_basic_1.json | 13 ++ .../alertconfig_basic_2.json | 16 ++ .../alertconfig_basic_3.json | 13 ++ .../alertconfig_basic_4.json | 16 ++ .../alertconfig_bearer_1.json | 17 ++ .../alertconfig_bearer_2.json | 17 ++ .../alertconfig_bearer_3.json | 17 ++ .../alertconfig_bearer_4.json | 17 ++ .../valid_config/alertconfig_basic_1.json | 32 +++ .../valid_config/alertconfig_bearer_1.json | 30 +++ tests/test_alert.py | 220 ++++++++++++++++++ 11 files changed, 408 insertions(+) create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_1.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_2.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_3.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_4.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json create mode 100644 tests/data/alerting/valid_config/alertconfig_basic_1.json create mode 100644 tests/data/alerting/valid_config/alertconfig_bearer_1.json diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_1.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_1.json new file mode 100644 index 000000000..9421486d8 --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_1.json @@ -0,0 +1,13 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic" + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_2.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_2.json new file mode 100644 index 000000000..cadb6bbf1 --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_2.json @@ -0,0 +1,16 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username": "" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_3.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_3.json new file mode 100644 index 000000000..3a8e95b2b --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_3.json @@ -0,0 +1,13 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic" + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_4.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_4.json new file mode 100644 index 000000000..8178a785a --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_4.json @@ -0,0 +1,16 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username": "" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json new file mode 100644 index 000000000..4f5d5877a --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json @@ -0,0 +1,17 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "123", + "token_file": "123" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json new file mode 100644 index 000000000..08b83eb78 --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json @@ -0,0 +1,17 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_basic": { + "username": "", + "password": "" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json new file mode 100644 index 000000000..6516904fb --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json @@ -0,0 +1,17 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "123", + "token_file": "123" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json new file mode 100644 index 000000000..12d56eeec --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json @@ -0,0 +1,17 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_basic": { + "username": "", + "password": "" + } + } + ] + } +} diff --git a/tests/data/alerting/valid_config/alertconfig_basic_1.json b/tests/data/alerting/valid_config/alertconfig_basic_1.json new file mode 100644 index 000000000..0751fe4c6 --- /dev/null +++ b/tests/data/alerting/valid_config/alertconfig_basic_1.json @@ -0,0 +1,32 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "", + "password_env": "" + } + } + ] + }, + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "", + "password_env": "" + } + } + ] + } +} diff --git a/tests/data/alerting/valid_config/alertconfig_bearer_1.json b/tests/data/alerting/valid_config/alertconfig_bearer_1.json new file mode 100644 index 000000000..44ae7fba2 --- /dev/null +++ b/tests/data/alerting/valid_config/alertconfig_bearer_1.json @@ -0,0 +1,30 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "" + } + } + ] + }, + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_file": "" + } + } + ] + } +} diff --git a/tests/test_alert.py b/tests/test_alert.py index e3ec661aa..3bd54ad19 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -1,3 +1,4 @@ +import os import pytest from datetime import datetime, timedelta import json @@ -57,6 +58,55 @@ "template": "custom", } +receiver_config_bearer_env = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "CONNAISSEUR_ALERTING_TOKEN", + }, + "template": "slack", +} + +receiver_config_bearer_file = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_file": "/tmp/token123456", + }, + "template": "slack", +} + +receiver_config_bearer_env_prefix = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "CONNAISSEUR_ALERTING_TOKEN", + "authorization_prefix": "Newprefix", + }, + "template": "slack", +} + +receiver_config_basic = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "CONNAISSEUR_ALERTING_USERNAME", + "password_env": "CONNAISSEUR_ALERTING_PASSWORD", + }, + "template": "slack", +} + +receiver_config_basic_prefix = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "CONNAISSEUR_ALERTING_USERNAME", + "password_env": "CONNAISSEUR_ALERTING_PASSWORD", + "authorization_prefix": "Newprefix", + }, + "template": "slack", +} + keybase_receiver_config = { "custom_headers": ["Content-Language: de-DE"], "fail_if_alert_sending_fails": True, @@ -129,6 +179,57 @@ pytest.raises(ConfigurationError, match=r".*invalid format.*"), ), ("/", True, pytest.raises(ConfigurationError, match=r".*error occurred.*")), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_1.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_2.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_3.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_4.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/valid_config/alertconfig_bearer_1.json", + False, + fix.no_exc(), + ), + ( + "tests/data/alerting/valid_config/alertconfig_basic_1.json", + False, + fix.no_exc(), + ), + ("tests/data/alerting/missing.json", True, fix.no_exc()), ], ) def test_alert_config_init( @@ -259,6 +360,125 @@ def test_alert_init( assert alert_payload == json.loads(alert_.payload) +@pytest.mark.parametrize( + "receiver_config, envs, files, headers_count, header, exception", + [ + ( + receiver_config_bearer_env, + {"CONNAISSEUR_ALERTING_TOKEN": "AAABBBCCCDDD"}, + {}, + 2, + {"Authorization": "Bearer AAABBBCCCDDD"}, + fix.no_exc(), + ), + ( + receiver_config_bearer_env_prefix, + {"CONNAISSEUR_ALERTING_TOKEN": "AAABBBCCCDDD"}, + {}, + 2, + {"Authorization": "Newprefix AAABBBCCCDDD"}, + fix.no_exc(), + ), + ( + receiver_config_bearer_file, + {}, + {"/tmp/token123456": "AAABBBCCCDDDEEE"}, + 2, + {"Authorization": "Bearer AAABBBCCCDDDEEE"}, + fix.no_exc(), + ), + ( + receiver_config_bearer_env, + {"CONNAISSEUR_ALERTING_TOKEN": ""}, + {}, + 1, + {}, + pytest.raises( + ConfigurationError, + match=r"No token found from environmental variable.*", + ), + ), + ( + receiver_config_bearer_file, + {}, + {}, + 1, + {}, + pytest.raises(ConfigurationError, match=r"No token file found.*"), + ), + ( + receiver_config_basic, + { + "CONNAISSEUR_ALERTING_USERNAME": "user", + "CONNAISSEUR_ALERTING_PASSWORD": "password", + }, + {}, + 2, + {"Authorization": "Basic user:password"}, + fix.no_exc(), + ), + ( + receiver_config_basic_prefix, + { + "CONNAISSEUR_ALERTING_USERNAME": "user", + "CONNAISSEUR_ALERTING_PASSWORD": "password", + }, + {}, + 2, + {"Authorization": "Newprefix user:password"}, + fix.no_exc(), + ), + ( + receiver_config_basic, + {"CONNAISSEUR_ALERTING_USERNAME": "", "CONNAISSEUR_ALERTING_PASSWORD": ""}, + {}, + 1, + {}, + pytest.raises( + ConfigurationError, + match=r"No username or password found from environmental variables.*", + ), + ), + ], +) +def test_alert_init_auth( + monkeypatch, + m_ad_schema_path, + m_alerting_without_send, + receiver_config: dict, + envs: dict, + files: dict, + headers_count: int, + header: dict, + exception, +): + alert_message = "Alert Message" + admission_request = admission_request_deployment + + for env, value in envs.items(): + if value == "": + if env in os.environ: + del os.environ[env] + else: + os.environ[env] = envs[env] + # monkeypatch.setenv(os.environ[env], envs[env]) + + for filepath, content in files.items(): + with open(filepath, "w") as f: + f.write(content) + + with exception: + alert_ = alert.Alert( + alert_message, receiver_config, AdmissionRequest(admission_request) + ) + assert len(alert_.headers) == headers_count + assert header.items() <= alert_.headers.items() + # assert list(header.keys())[0] in alert_.headers.keys() + + for filepath in files: + os.remove(filepath) + + @pytest.mark.parametrize( "receiver_config, key, status, out, exception", [ From bd88ef37efb4217001a3ef9272a26480d8ea2805 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 24 Feb 2022 18:52:34 +0100 Subject: [PATCH 03/22] updated documentation for alert authentication --- docs/features/alerting.md | 75 ++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/docs/features/alerting.md b/docs/features/alerting.md index cb08b6748..b45642fe0 100644 --- a/docs/features/alerting.md +++ b/docs/features/alerting.md @@ -16,15 +16,24 @@ alerts at the same time. Currently, Connaisseur supports alerting on either admittance of images, denial of images or both. These event categories can be configured independently of each other under the relevant category (i.e. `admit_request` or `reject_request`): -| Key | Accepted values | Default | Required | Description | -| -------------------------------------------------- | ---------------------------------------------------- | ----------------- | ------------------ | -------------------------------------------------------------------------------------------------- | -| `alerting.cluster_identifier` | string | `"not specified"` | | Cluster identifier used in alert payload to distinguish between alerts from different clusters. | -| `alerting..template` | `opsgenie`, `slack`, `keybase`, `ecs-1-12-0` or custom* | - | :heavy_check_mark: | File in `helm/alert_payload_templates/` to be used as alert payload template. | -| `alerting..receiver_url` | string | - | :heavy_check_mark: | URL of alert-receiving endpoint. | -| `alerting..priority` | int | `3` | | Priority of alert (to enable fitting Connaisseur alerts into alerts from other sources). | -| `alerting..custom_headers` | list[string] | - | | Additional headers required by alert-receiving endpoint. | -| `alerting..payload_fields` | subyaml | - | | Additional (`yaml`) key-value pairs to be appended to alert payload (as `json`). | -| `alerting..fail_if_alert_sending_fails` | bool | `False` | | Whether to make Connaisseur deny images if the corresponding alert cannot be successfully sent. | +| Key | Accepted values | Default | Required | Description | +| --------------------------------------------------- | ----------------------------------------------------- | ----------------- | ------------------ | --------------------------------------------------------------------------------------------------- | +| `alerting.cluster_identifier` | string | `"not specified"` | | Cluster identifier used in alert payload to distinguish between alerts from different clusters. | +| `alerting..template` | `opsgenie`, `slack`, `keybase`, `ecs-1-12-0` or custom* | - | :heavy_check_mark: | File in `helm/alert_payload_templates/` to be used as alert payload template. | +| `alerting..receiver_url` | string | - | :heavy_check_mark: | URL of alert-receiving endpoint. | +| `alerting..priority` | int | `3` | | Priority of alert (to enable fitting Connaisseur alerts into alerts from other sources). | +| `alerting..custom_headers` | list[string] | - | | Additional headers required by alert-receiving endpoint. | +| `alerting..payload_fields` | subyaml | - | | Additional (`yaml`) key-value pairs to be appended to alert payload (as `json`). | +| `alerting..fail_if_alert_sending_fails` | bool | `False` | | Whether to make Connaisseur deny images if the corresponding alert cannot be successfully sent. | +| `alerting..receiver_authentication_type` | string enum `basic`, `bearer`, `none` | `none` | | Authentication type of the alert-receiving webhook endpoint . | +| `alerting..receiver_authentication_basic` | object | - | only when `receiver_authentication_type` is `basic` | Authentication credentials for basic authentication. | +| `alerting..receiver_authentication_basic.username_env` | string | - | only when `receiver_authentication_type` is `basic` | Username Environmental variable for basic authentication. | +| `alerting..receiver_authentication_basic.password_env` | string | - | only when `receiver_authentication_type` is `basic` | Password Environmental variable for basic authentication. | +| `alerting..receiver_authentication_basic.authorization_prefix` | string | `Basic` | | Prefix for Authorization header for basic authentication. | +| `alerting..receiver_authentication_bearer` | object | - | only when `receiver_authentication_type` is `bearer` | Authentication credentials for bearer authentication. | +| `alerting..receiver_authentication_bearer.token_env` | string | - | only when `receiver_authentication_type` is `bearer` | Token Environmental variable for bearer authentication (Exclusive with `token_file`). | +| `alerting..receiver_authentication_bearer.token_file` | string | - | only when `receiver_authentication_type` is `bearer` | Token file for bearer authentication (Exclusive with `token_env`). | +| `alerting..receiver_authentication_bearer.authorization_prefix` | string | `Bearer` | | Prefix for Authorization header for bearer authentication. | *basename of the custom template file in `helm/alerting_payload_templates` without file extension @@ -52,6 +61,54 @@ alerting: receiver_url: https://bots.keybase.io/webhookbot/ ``` +## Example With Authentication + +For example, if you would like to receive notifications in your custom webhook authenticated with a bearer token taken from an environmental variable whenever Connaisseur admits a request to your cluster, your alerting configuration would look similar to the following snippet: + +``` +alerting: + admit_request: + templates: + - template: ecs-1-12-0 + receiver_url: https://your.custom.domain.com/webhook/admit + receiver_authentication_type: bearer + receiver_authentication_bearer: + token_env: CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_TOKEN +``` + +You then have to set the `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_TOKEN` environment variable referencing the bearer token secret you want to use into the connaisseur deployment. + +Or if you would like to use the service account token as the bearer token, you can use the following snippet: + +``` +alerting: + admit_request: + templates: + - template: ecs-1-12-0 + receiver_url: https://your.custom.domain.com/webhook/admit + receiver_authentication_type: bearer + receiver_authentication_bearer: + token_file: /var/run/secrets/kubernetes.io/serviceaccount/token +``` + +Finally in case of basic authentication, you can use the following snippet: + + +``` +alerting: + admit_request: + templates: + - template: ecs-1-12-0 + receiver_url: https://your.custom.domain.com/webhook/admit + receiver_authentication_type: basic + receiver_authentication_basic: + username_env: CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_USERNAME + password_env: CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_PASSWORD +``` + +You then have to set the `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_USERNAME` and `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_PASSWORD` environment variables referencing the secret you want to use into the connaisseur deployment. + + ## Additional notes ### Creating a custom template From 1c3d61e71af2a1074874d491aed5b45a56ee960f Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 24 Feb 2022 18:54:15 +0100 Subject: [PATCH 04/22] fixed yq command in makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 00d4ccdbc..cb4ce444b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ NAMESPACE = connaisseur -IMAGE := $(shell yq e '.deployment.image' helm/values.yaml) +IMAGE := $(shell yq -e '.deployment.image' helm/values.yaml) COSIGN_VERSION = 1.5.1 .PHONY: all docker install unistall upgrade annihilate From 3de5ef9b0005e1a2074fef03a74cf100f9f467ec Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 24 Feb 2022 18:45:42 +0100 Subject: [PATCH 05/22] added support for authentication in the alert (both basic and bearer). --- connaisseur/alert.py | 171 +++++++++++++++++++++++- connaisseur/res/alertconfig_schema.json | 148 +++++++++++++++++++- 2 files changed, 316 insertions(+), 3 deletions(-) diff --git a/connaisseur/alert.py b/connaisseur/alert.py index a8fdef4bf..f73117018 100644 --- a/connaisseur/alert.py +++ b/connaisseur/alert.py @@ -48,6 +48,166 @@ def alerting_required(self, event_category: str) -> bool: return bool(self.config.get(event_category)) +class AlertReceiverAuthentication: + """ + Class to store authentication information for securely sending events to the alert receiver. + """ + + class AlertReceiverBasicAuthentication: + """ + Class to store authentication information for basic authentication type with username and password. + """ + username: str + password: str + authentication_type: str + authorization_prefix: str = "Basic" + + def __init__(self, alert_receiver_config: dict): + basic_authentication_config = alert_receiver_config.get( + "receiver_authentication_basic", None + ) + + if ( + basic_authentication_config is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError("No basic authentication configuration found.") + + username_env = basic_authentication_config.get("username_env", None) + password_env = basic_authentication_config.get("password_env", None) + + if ( + username_env is None or password_env is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "No username_env or password_env configuration found." + ) + + self.username = os.environ.get(username_env, None) + self.password = os.environ.get(password_env, None) + + if self.username is None or self.password is None: + raise ConfigurationError( + f"No username or password found from environmental variables {username_env} and {password_env}." + ) + + self.authorization_prefix = basic_authentication_config.get( + "authorization_prefix", "Basic" + ) + # TODO maybe validate authorization prefix + + def get_header(self) -> dict: + return { + "Authorization": f"{self.authorization_prefix} {self.username}:{self.password}" + } + + class AlertReceiverBearerAuthentication: + """ + Class to store authentication information for bearer authentication type which uses a token. + """ + token: str + authorization_prefix: str = "Bearer" # default is bearer + + def __init__(self, alert_receiver_config: dict): + bearer_authentication_config = alert_receiver_config.get( + "receiver_authentication_bearer", None + ) + + if ( + bearer_authentication_config is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "No bearer authentication configuration found." + ) + + token_env = bearer_authentication_config.get("token_env", None) + token_file = bearer_authentication_config.get("token_file", None) + + if ( + token_env is None and token_file is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "No token_env and token_file configuration found." + ) + + if ( + token_env is not None and token_file is not None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "Both token_env and token_file configuration found. Only one is required." + ) + + if token_env is not None: + self.token = os.environ.get(token_env, None) + + if self.token is None: + raise ConfigurationError( + f"No token found from environmental variable {token_env}." + ) + else: + try: + with open(token_file, "r") as token_file: + self.token = token_file.read() + except FileNotFoundError: + raise ConfigurationError(f"No token file found at {token_file}.") + except Exception as err: + raise ConfigurationError( + f"An error occurred while loading the token file {token_file}: {str(err)}" + ) + + self.authorization_prefix = bearer_authentication_config.get( + "authorization_prefix", "Bearer" + ) + # TODO maybe validate authorization prefix + + def get_header(self) -> dict: + return {"Authorization": f"{self.authorization_prefix} {self.token}"} + + def __init__(self, alert_receiver_config: dict): + self.authentication_type = alert_receiver_config.get( + "receiver_authentication_type", "none" + ) + + if self.is_basic(): + self.__init_basic_authentication(alert_receiver_config) + elif self.is_bearer(): + self.__init_bearer_authentication(alert_receiver_config) + + def is_basic(self): + return self.authentication_type == "basic" + + def is_bearer(self): + return self.authentication_type == "bearer" + + def is_none(self): + return self.authentication_type == "none" + + def __init_bearer_authentication(self, alert_receiver_config: dict): + self.bearer_authentication = ( + AlertReceiverAuthentication.AlertReceiverBearerAuthentication( + alert_receiver_config + ) + ) + + def __init_basic_authentication(self, alert_receiver_config: dict): + self.basic_authentication = ( + AlertReceiverAuthentication.AlertReceiverBasicAuthentication( + alert_receiver_config + ) + ) + + def get_auth_header(self) -> dict: + if self.is_basic(): + return self.basic_authentication.get_header() + elif self.is_bearer(): + return self.bearer_authentication.get_header() + elif self.is_none(): + return {} + else: + raise ConfigurationError( + "No authentication type found." + ) # hopefully this never happens + + class Alert: """ Class to store image information about an alert as attributes and a sending @@ -59,6 +219,7 @@ class Alert: template: str receiver_url: str + receiver_authentication: AlertReceiverAuthentication payload: str headers: dict @@ -101,12 +262,13 @@ def __init__( "images": images, } self.receiver_url = receiver_config["receiver_url"] + self.receiver_authentication = AlertReceiverAuthentication(receiver_config) self.template = receiver_config["template"] self.throw_if_alert_sending_fails = receiver_config.get( "fail_if_alert_sending_fails", False ) self.payload = self.__construct_payload(receiver_config) - self.headers = self.__get_headers(receiver_config) + self.headers = self.__get_headers(receiver_config, self.receiver_authentication) def __construct_payload(self, receiver_config: dict) -> str: try: @@ -153,13 +315,18 @@ def send_alert(self) -> Optional[requests.Response]: return response @staticmethod - def __get_headers(receiver_config): + def __get_headers( + receiver_config: dict, receiver_authentication: AlertReceiverAuthentication + ) -> dict: headers = {"Content-Type": "application/json"} additional_headers = receiver_config.get("custom_headers") if additional_headers is not None: for header in additional_headers: key, value = header.split(":", 1) headers.update({key.strip(): value.strip()}) + auth_header = receiver_authentication.get_auth_header() + if auth_header: # not None and not empty + headers.update(auth_header) return headers diff --git a/connaisseur/res/alertconfig_schema.json b/connaisseur/res/alertconfig_schema.json index 8a894b2b1..72d405d34 100644 --- a/connaisseur/res/alertconfig_schema.json +++ b/connaisseur/res/alertconfig_schema.json @@ -18,6 +18,50 @@ "receiver_url": { "type": "string" }, + "receiver_authentication_type": { + "type": "string", + "enum": [ + "none", + "basic", + "bearer" + ] + }, + "receiver_authentication_basic": { + "type": "object", + "properties": { + "username_env": { + "type": "string" + }, + "password_env": { + "type": "string" + }, + "authorization_prefix": { + "type": "string" + } + }, + "required": [ + "username_env", + "password_env" + ] + }, + "receiver_authentication_bearer": { + "type": "object", + "oneOf": [ + {"required": ["token_file"]}, + {"required": ["token_env"]} + ], + "properties": { + "authorization_prefix": { + "type": "string" + }, + "token_file": { + "type": "string" + }, + "token_env": { + "type": "string" + } + } + }, "priority": { "type": "integer" }, @@ -35,6 +79,35 @@ "type": "boolean" } }, + "anyOf": [ + { + "properties": { + "receiver_authentication_type": { + "const": "basic" + } + }, + "required": [ + "receiver_authentication_basic" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "bearer" + } + }, + "required": [ + "receiver_authentication_bearer" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "none" + } + } + } + ], "required": [ "template", "receiver_url" @@ -60,6 +133,50 @@ "receiver_url": { "type": "string" }, + "receiver_authentication_type": { + "type": "string", + "enum": [ + "none", + "basic", + "bearer" + ] + }, + "receiver_authentication_basic": { + "type": "object", + "properties": { + "username_env": { + "type": "string" + }, + "password_env": { + "type": "string" + }, + "authorization_prefix": { + "type": "string" + } + }, + "required": [ + "username_env", + "password_env" + ] + }, + "receiver_authentication_bearer": { + "type": "object", + "oneOf": [ + {"required": ["token_file"]}, + {"required": ["token_env"]} + ], + "properties": { + "authorization_prefix": { + "type": "string" + }, + "token_file": { + "type": "string" + }, + "token_env": { + "type": "string" + } + } + }, "priority": { "type": "integer" }, @@ -77,6 +194,35 @@ "type": "boolean" } }, + "anyOf": [ + { + "properties": { + "receiver_authentication_type": { + "const": "basic" + } + }, + "required": [ + "receiver_authentication_basic" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "bearer" + } + }, + "required": [ + "receiver_authentication_bearer" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "none" + } + } + } + ], "required": [ "template", "receiver_url" @@ -89,4 +235,4 @@ ] } } -} +} \ No newline at end of file From 51ea8a43140dfdb20f1bede2680cf18085be38f9 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 24 Feb 2022 18:47:26 +0100 Subject: [PATCH 06/22] added unit tests for alert authentication --- .../alertconfig_basic_1.json | 13 ++ .../alertconfig_basic_2.json | 16 ++ .../alertconfig_basic_3.json | 13 ++ .../alertconfig_basic_4.json | 16 ++ .../alertconfig_bearer_1.json | 17 ++ .../alertconfig_bearer_2.json | 17 ++ .../alertconfig_bearer_3.json | 17 ++ .../alertconfig_bearer_4.json | 17 ++ .../valid_config/alertconfig_basic_1.json | 32 +++ .../valid_config/alertconfig_bearer_1.json | 30 +++ tests/test_alert.py | 220 ++++++++++++++++++ 11 files changed, 408 insertions(+) create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_1.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_2.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_3.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_4.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json create mode 100644 tests/data/alerting/valid_config/alertconfig_basic_1.json create mode 100644 tests/data/alerting/valid_config/alertconfig_bearer_1.json diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_1.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_1.json new file mode 100644 index 000000000..9421486d8 --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_1.json @@ -0,0 +1,13 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic" + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_2.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_2.json new file mode 100644 index 000000000..cadb6bbf1 --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_2.json @@ -0,0 +1,16 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username": "" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_3.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_3.json new file mode 100644 index 000000000..3a8e95b2b --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_3.json @@ -0,0 +1,13 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic" + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_4.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_4.json new file mode 100644 index 000000000..8178a785a --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_4.json @@ -0,0 +1,16 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username": "" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json new file mode 100644 index 000000000..4f5d5877a --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json @@ -0,0 +1,17 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "123", + "token_file": "123" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json new file mode 100644 index 000000000..08b83eb78 --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json @@ -0,0 +1,17 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_basic": { + "username": "", + "password": "" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json new file mode 100644 index 000000000..6516904fb --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json @@ -0,0 +1,17 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "123", + "token_file": "123" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json new file mode 100644 index 000000000..12d56eeec --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json @@ -0,0 +1,17 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_basic": { + "username": "", + "password": "" + } + } + ] + } +} diff --git a/tests/data/alerting/valid_config/alertconfig_basic_1.json b/tests/data/alerting/valid_config/alertconfig_basic_1.json new file mode 100644 index 000000000..0751fe4c6 --- /dev/null +++ b/tests/data/alerting/valid_config/alertconfig_basic_1.json @@ -0,0 +1,32 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "", + "password_env": "" + } + } + ] + }, + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "", + "password_env": "" + } + } + ] + } +} diff --git a/tests/data/alerting/valid_config/alertconfig_bearer_1.json b/tests/data/alerting/valid_config/alertconfig_bearer_1.json new file mode 100644 index 000000000..44ae7fba2 --- /dev/null +++ b/tests/data/alerting/valid_config/alertconfig_bearer_1.json @@ -0,0 +1,30 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "" + } + } + ] + }, + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_file": "" + } + } + ] + } +} diff --git a/tests/test_alert.py b/tests/test_alert.py index e3ec661aa..3bd54ad19 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -1,3 +1,4 @@ +import os import pytest from datetime import datetime, timedelta import json @@ -57,6 +58,55 @@ "template": "custom", } +receiver_config_bearer_env = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "CONNAISSEUR_ALERTING_TOKEN", + }, + "template": "slack", +} + +receiver_config_bearer_file = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_file": "/tmp/token123456", + }, + "template": "slack", +} + +receiver_config_bearer_env_prefix = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "CONNAISSEUR_ALERTING_TOKEN", + "authorization_prefix": "Newprefix", + }, + "template": "slack", +} + +receiver_config_basic = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "CONNAISSEUR_ALERTING_USERNAME", + "password_env": "CONNAISSEUR_ALERTING_PASSWORD", + }, + "template": "slack", +} + +receiver_config_basic_prefix = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "CONNAISSEUR_ALERTING_USERNAME", + "password_env": "CONNAISSEUR_ALERTING_PASSWORD", + "authorization_prefix": "Newprefix", + }, + "template": "slack", +} + keybase_receiver_config = { "custom_headers": ["Content-Language: de-DE"], "fail_if_alert_sending_fails": True, @@ -129,6 +179,57 @@ pytest.raises(ConfigurationError, match=r".*invalid format.*"), ), ("/", True, pytest.raises(ConfigurationError, match=r".*error occurred.*")), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_1.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_2.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_3.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_4.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/valid_config/alertconfig_bearer_1.json", + False, + fix.no_exc(), + ), + ( + "tests/data/alerting/valid_config/alertconfig_basic_1.json", + False, + fix.no_exc(), + ), + ("tests/data/alerting/missing.json", True, fix.no_exc()), ], ) def test_alert_config_init( @@ -259,6 +360,125 @@ def test_alert_init( assert alert_payload == json.loads(alert_.payload) +@pytest.mark.parametrize( + "receiver_config, envs, files, headers_count, header, exception", + [ + ( + receiver_config_bearer_env, + {"CONNAISSEUR_ALERTING_TOKEN": "AAABBBCCCDDD"}, + {}, + 2, + {"Authorization": "Bearer AAABBBCCCDDD"}, + fix.no_exc(), + ), + ( + receiver_config_bearer_env_prefix, + {"CONNAISSEUR_ALERTING_TOKEN": "AAABBBCCCDDD"}, + {}, + 2, + {"Authorization": "Newprefix AAABBBCCCDDD"}, + fix.no_exc(), + ), + ( + receiver_config_bearer_file, + {}, + {"/tmp/token123456": "AAABBBCCCDDDEEE"}, + 2, + {"Authorization": "Bearer AAABBBCCCDDDEEE"}, + fix.no_exc(), + ), + ( + receiver_config_bearer_env, + {"CONNAISSEUR_ALERTING_TOKEN": ""}, + {}, + 1, + {}, + pytest.raises( + ConfigurationError, + match=r"No token found from environmental variable.*", + ), + ), + ( + receiver_config_bearer_file, + {}, + {}, + 1, + {}, + pytest.raises(ConfigurationError, match=r"No token file found.*"), + ), + ( + receiver_config_basic, + { + "CONNAISSEUR_ALERTING_USERNAME": "user", + "CONNAISSEUR_ALERTING_PASSWORD": "password", + }, + {}, + 2, + {"Authorization": "Basic user:password"}, + fix.no_exc(), + ), + ( + receiver_config_basic_prefix, + { + "CONNAISSEUR_ALERTING_USERNAME": "user", + "CONNAISSEUR_ALERTING_PASSWORD": "password", + }, + {}, + 2, + {"Authorization": "Newprefix user:password"}, + fix.no_exc(), + ), + ( + receiver_config_basic, + {"CONNAISSEUR_ALERTING_USERNAME": "", "CONNAISSEUR_ALERTING_PASSWORD": ""}, + {}, + 1, + {}, + pytest.raises( + ConfigurationError, + match=r"No username or password found from environmental variables.*", + ), + ), + ], +) +def test_alert_init_auth( + monkeypatch, + m_ad_schema_path, + m_alerting_without_send, + receiver_config: dict, + envs: dict, + files: dict, + headers_count: int, + header: dict, + exception, +): + alert_message = "Alert Message" + admission_request = admission_request_deployment + + for env, value in envs.items(): + if value == "": + if env in os.environ: + del os.environ[env] + else: + os.environ[env] = envs[env] + # monkeypatch.setenv(os.environ[env], envs[env]) + + for filepath, content in files.items(): + with open(filepath, "w") as f: + f.write(content) + + with exception: + alert_ = alert.Alert( + alert_message, receiver_config, AdmissionRequest(admission_request) + ) + assert len(alert_.headers) == headers_count + assert header.items() <= alert_.headers.items() + # assert list(header.keys())[0] in alert_.headers.keys() + + for filepath in files: + os.remove(filepath) + + @pytest.mark.parametrize( "receiver_config, key, status, out, exception", [ From 9a10f8f1a4e30dd3d7d7fa7d3efe79c767f99072 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 24 Feb 2022 18:52:34 +0100 Subject: [PATCH 07/22] updated documentation for alert authentication --- docs/features/alerting.md | 75 ++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/docs/features/alerting.md b/docs/features/alerting.md index cb08b6748..b45642fe0 100644 --- a/docs/features/alerting.md +++ b/docs/features/alerting.md @@ -16,15 +16,24 @@ alerts at the same time. Currently, Connaisseur supports alerting on either admittance of images, denial of images or both. These event categories can be configured independently of each other under the relevant category (i.e. `admit_request` or `reject_request`): -| Key | Accepted values | Default | Required | Description | -| -------------------------------------------------- | ---------------------------------------------------- | ----------------- | ------------------ | -------------------------------------------------------------------------------------------------- | -| `alerting.cluster_identifier` | string | `"not specified"` | | Cluster identifier used in alert payload to distinguish between alerts from different clusters. | -| `alerting..template` | `opsgenie`, `slack`, `keybase`, `ecs-1-12-0` or custom* | - | :heavy_check_mark: | File in `helm/alert_payload_templates/` to be used as alert payload template. | -| `alerting..receiver_url` | string | - | :heavy_check_mark: | URL of alert-receiving endpoint. | -| `alerting..priority` | int | `3` | | Priority of alert (to enable fitting Connaisseur alerts into alerts from other sources). | -| `alerting..custom_headers` | list[string] | - | | Additional headers required by alert-receiving endpoint. | -| `alerting..payload_fields` | subyaml | - | | Additional (`yaml`) key-value pairs to be appended to alert payload (as `json`). | -| `alerting..fail_if_alert_sending_fails` | bool | `False` | | Whether to make Connaisseur deny images if the corresponding alert cannot be successfully sent. | +| Key | Accepted values | Default | Required | Description | +| --------------------------------------------------- | ----------------------------------------------------- | ----------------- | ------------------ | --------------------------------------------------------------------------------------------------- | +| `alerting.cluster_identifier` | string | `"not specified"` | | Cluster identifier used in alert payload to distinguish between alerts from different clusters. | +| `alerting..template` | `opsgenie`, `slack`, `keybase`, `ecs-1-12-0` or custom* | - | :heavy_check_mark: | File in `helm/alert_payload_templates/` to be used as alert payload template. | +| `alerting..receiver_url` | string | - | :heavy_check_mark: | URL of alert-receiving endpoint. | +| `alerting..priority` | int | `3` | | Priority of alert (to enable fitting Connaisseur alerts into alerts from other sources). | +| `alerting..custom_headers` | list[string] | - | | Additional headers required by alert-receiving endpoint. | +| `alerting..payload_fields` | subyaml | - | | Additional (`yaml`) key-value pairs to be appended to alert payload (as `json`). | +| `alerting..fail_if_alert_sending_fails` | bool | `False` | | Whether to make Connaisseur deny images if the corresponding alert cannot be successfully sent. | +| `alerting..receiver_authentication_type` | string enum `basic`, `bearer`, `none` | `none` | | Authentication type of the alert-receiving webhook endpoint . | +| `alerting..receiver_authentication_basic` | object | - | only when `receiver_authentication_type` is `basic` | Authentication credentials for basic authentication. | +| `alerting..receiver_authentication_basic.username_env` | string | - | only when `receiver_authentication_type` is `basic` | Username Environmental variable for basic authentication. | +| `alerting..receiver_authentication_basic.password_env` | string | - | only when `receiver_authentication_type` is `basic` | Password Environmental variable for basic authentication. | +| `alerting..receiver_authentication_basic.authorization_prefix` | string | `Basic` | | Prefix for Authorization header for basic authentication. | +| `alerting..receiver_authentication_bearer` | object | - | only when `receiver_authentication_type` is `bearer` | Authentication credentials for bearer authentication. | +| `alerting..receiver_authentication_bearer.token_env` | string | - | only when `receiver_authentication_type` is `bearer` | Token Environmental variable for bearer authentication (Exclusive with `token_file`). | +| `alerting..receiver_authentication_bearer.token_file` | string | - | only when `receiver_authentication_type` is `bearer` | Token file for bearer authentication (Exclusive with `token_env`). | +| `alerting..receiver_authentication_bearer.authorization_prefix` | string | `Bearer` | | Prefix for Authorization header for bearer authentication. | *basename of the custom template file in `helm/alerting_payload_templates` without file extension @@ -52,6 +61,54 @@ alerting: receiver_url: https://bots.keybase.io/webhookbot/ ``` +## Example With Authentication + +For example, if you would like to receive notifications in your custom webhook authenticated with a bearer token taken from an environmental variable whenever Connaisseur admits a request to your cluster, your alerting configuration would look similar to the following snippet: + +``` +alerting: + admit_request: + templates: + - template: ecs-1-12-0 + receiver_url: https://your.custom.domain.com/webhook/admit + receiver_authentication_type: bearer + receiver_authentication_bearer: + token_env: CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_TOKEN +``` + +You then have to set the `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_TOKEN` environment variable referencing the bearer token secret you want to use into the connaisseur deployment. + +Or if you would like to use the service account token as the bearer token, you can use the following snippet: + +``` +alerting: + admit_request: + templates: + - template: ecs-1-12-0 + receiver_url: https://your.custom.domain.com/webhook/admit + receiver_authentication_type: bearer + receiver_authentication_bearer: + token_file: /var/run/secrets/kubernetes.io/serviceaccount/token +``` + +Finally in case of basic authentication, you can use the following snippet: + + +``` +alerting: + admit_request: + templates: + - template: ecs-1-12-0 + receiver_url: https://your.custom.domain.com/webhook/admit + receiver_authentication_type: basic + receiver_authentication_basic: + username_env: CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_USERNAME + password_env: CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_PASSWORD +``` + +You then have to set the `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_USERNAME` and `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_PASSWORD` environment variables referencing the secret you want to use into the connaisseur deployment. + + ## Additional notes ### Creating a custom template From 7613ba3abc3e9b2d2ad01b7d75c54d5c3b732fb4 Mon Sep 17 00:00:00 2001 From: Andrea Date: Mon, 28 Feb 2022 22:50:28 +0100 Subject: [PATCH 08/22] Improved alert class accordingly to the pull request --- connaisseur/alert.py | 160 ++++++++++++++++++++++--------------------- 1 file changed, 81 insertions(+), 79 deletions(-) diff --git a/connaisseur/alert.py b/connaisseur/alert.py index f73117018..e3a26d9cf 100644 --- a/connaisseur/alert.py +++ b/connaisseur/alert.py @@ -1,3 +1,4 @@ +from abc import abstractmethod import json import logging import os @@ -53,31 +54,66 @@ class AlertReceiverAuthentication: Class to store authentication information for securely sending events to the alert receiver. """ - class AlertReceiverBasicAuthentication: + authentication_config: dict = None + authentication_scheme: str = None + + class AlertReceiverAuthenticationInterface: + def __init__(self, alert_receiver_config: dict, authentication_key: str): + self.authentication_config = alert_receiver_config.get(authentication_key) + + if self.authentication_config is None: + raise ConfigurationError( + f"No authentication configuration found ({authentication_key})." + ) + + self.authentication_scheme = self.authentication_config.get( + "authentication_scheme", self.authentication_scheme + ) + self._validate_authentication_scheme() + + def _validate_authentication_scheme(self) -> None: + if not self.authentication_scheme: + raise ConfigurationError( + "The authentication scheme cannot be null or empty." + ) + + if " " in self.authentication_scheme: + raise ConfigurationError( + "The authentication scheme cannot contain any space." + ) + + @abstractmethod + def get_header(self) -> dict: + pass + + class AlertReceiverNoneAuthentication(AlertReceiverAuthenticationInterface): + """ + Placeholder class for AlertReceiver without authentication. + """ + def __init__(self, alert_receiver_config: dict): + pass + + def get_header(self) -> dict: + return {} + + class AlertReceiverBasicAuthentication(AlertReceiverAuthenticationInterface): """ Class to store authentication information for basic authentication type with username and password. """ + username: str password: str - authentication_type: str - authorization_prefix: str = "Basic" + authentication_scheme: str = "Basic" def __init__(self, alert_receiver_config: dict): - basic_authentication_config = alert_receiver_config.get( - "receiver_authentication_basic", None - ) - - if ( - basic_authentication_config is None - ): # TODO maybe remove this check since it is included in the json validation? - raise ConfigurationError("No basic authentication configuration found.") + super().__init__(alert_receiver_config, "receiver_authentication_basic") - username_env = basic_authentication_config.get("username_env", None) - password_env = basic_authentication_config.get("password_env", None) + username_env = self.authentication_config.get("username_env") + password_env = self.authentication_config.get("password_env") if ( username_env is None or password_env is None - ): # TODO maybe remove this check since it is included in the json validation? + ): # This should not happen since it is included in the json validation raise ConfigurationError( "No username_env or password_env configuration found." ) @@ -90,50 +126,37 @@ def __init__(self, alert_receiver_config: dict): f"No username or password found from environmental variables {username_env} and {password_env}." ) - self.authorization_prefix = basic_authentication_config.get( - "authorization_prefix", "Basic" - ) - # TODO maybe validate authorization prefix - def get_header(self) -> dict: return { - "Authorization": f"{self.authorization_prefix} {self.username}:{self.password}" + "Authorization": f"{self.authentication_scheme} {self.username}:{self.password}" } - class AlertReceiverBearerAuthentication: + class AlertReceiverBearerAuthentication(AlertReceiverAuthenticationInterface): """ Class to store authentication information for bearer authentication type which uses a token. """ + token: str - authorization_prefix: str = "Bearer" # default is bearer + authentication_scheme: str = "Bearer" # default is bearer def __init__(self, alert_receiver_config: dict): - bearer_authentication_config = alert_receiver_config.get( - "receiver_authentication_bearer", None - ) - - if ( - bearer_authentication_config is None - ): # TODO maybe remove this check since it is included in the json validation? - raise ConfigurationError( - "No bearer authentication configuration found." - ) - - token_env = bearer_authentication_config.get("token_env", None) - token_file = bearer_authentication_config.get("token_file", None) + super().__init__(alert_receiver_config, "receiver_authentication_bearer") + + token_env = self.authentication_config.get("token_env") + token_file = self.authentication_config.get("token_file") if ( token_env is None and token_file is None - ): # TODO maybe remove this check since it is included in the json validation? + ): # This should not happen since it is included in the json validation raise ConfigurationError( "No token_env and token_file configuration found." ) if ( token_env is not None and token_file is not None - ): # TODO maybe remove this check since it is included in the json validation? + ): # This should not happen since it is included in the json validation raise ConfigurationError( - "Both token_env and token_file configuration found. Only one is required." + "Both token_env and token_file configuration found. Only one can be given." ) if token_env is not None: @@ -154,58 +177,37 @@ def __init__(self, alert_receiver_config: dict): f"An error occurred while loading the token file {token_file}: {str(err)}" ) - self.authorization_prefix = bearer_authentication_config.get( - "authorization_prefix", "Bearer" - ) - # TODO maybe validate authorization prefix - def get_header(self) -> dict: - return {"Authorization": f"{self.authorization_prefix} {self.token}"} + return {"Authorization": f"{self.authentication_scheme} {self.token}"} + + init_map = { + "basic": AlertReceiverBasicAuthentication, + "bearer": AlertReceiverBearerAuthentication, + "none": AlertReceiverNoneAuthentication, + } + + _authentication_instance = None def __init__(self, alert_receiver_config: dict): self.authentication_type = alert_receiver_config.get( "receiver_authentication_type", "none" ) + self.__init_authentication_instance(alert_receiver_config) - if self.is_basic(): - self.__init_basic_authentication(alert_receiver_config) - elif self.is_bearer(): - self.__init_bearer_authentication(alert_receiver_config) + def __init_authentication_instance(self, alert_receiver_config: dict): + authentication_class = self.__get_authentication_class() + self._authentication_instance = authentication_class(alert_receiver_config) - def is_basic(self): - return self.authentication_type == "basic" - - def is_bearer(self): - return self.authentication_type == "bearer" + def __get_authentication_class(self): + if self.authentication_type not in AlertReceiverAuthentication.init_map.keys(): + raise ConfigurationError( + f"No authentication type found. Valid values are {list(AlertReceiverAuthentication.init_map.keys())}" + ) # hopefully this never happens - def is_none(self): - return self.authentication_type == "none" - - def __init_bearer_authentication(self, alert_receiver_config: dict): - self.bearer_authentication = ( - AlertReceiverAuthentication.AlertReceiverBearerAuthentication( - alert_receiver_config - ) - ) - - def __init_basic_authentication(self, alert_receiver_config: dict): - self.basic_authentication = ( - AlertReceiverAuthentication.AlertReceiverBasicAuthentication( - alert_receiver_config - ) - ) + return self.init_map.get(self.authentication_type) def get_auth_header(self) -> dict: - if self.is_basic(): - return self.basic_authentication.get_header() - elif self.is_bearer(): - return self.bearer_authentication.get_header() - elif self.is_none(): - return {} - else: - raise ConfigurationError( - "No authentication type found." - ) # hopefully this never happens + return self._authentication_instance.get_header() class Alert: From 3d383a585a522f1e379f250eb070108500fa49f6 Mon Sep 17 00:00:00 2001 From: Andrea Date: Mon, 28 Feb 2022 22:52:41 +0100 Subject: [PATCH 09/22] Updated alert documentation --- docs/features/alerting.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/alerting.md b/docs/features/alerting.md index b45642fe0..1ba423f49 100644 --- a/docs/features/alerting.md +++ b/docs/features/alerting.md @@ -29,11 +29,11 @@ Currently, Connaisseur supports alerting on either admittance of images, denial | `alerting..receiver_authentication_basic` | object | - | only when `receiver_authentication_type` is `basic` | Authentication credentials for basic authentication. | | `alerting..receiver_authentication_basic.username_env` | string | - | only when `receiver_authentication_type` is `basic` | Username Environmental variable for basic authentication. | | `alerting..receiver_authentication_basic.password_env` | string | - | only when `receiver_authentication_type` is `basic` | Password Environmental variable for basic authentication. | -| `alerting..receiver_authentication_basic.authorization_prefix` | string | `Basic` | | Prefix for Authorization header for basic authentication. | +| `alerting..receiver_authentication_basic.authentication_scheme` | string (without spaces) | `Basic` | | Prefix for Authorization header for basic authentication. | | `alerting..receiver_authentication_bearer` | object | - | only when `receiver_authentication_type` is `bearer` | Authentication credentials for bearer authentication. | | `alerting..receiver_authentication_bearer.token_env` | string | - | only when `receiver_authentication_type` is `bearer` | Token Environmental variable for bearer authentication (Exclusive with `token_file`). | | `alerting..receiver_authentication_bearer.token_file` | string | - | only when `receiver_authentication_type` is `bearer` | Token file for bearer authentication (Exclusive with `token_env`). | -| `alerting..receiver_authentication_bearer.authorization_prefix` | string | `Bearer` | | Prefix for Authorization header for bearer authentication. | +| `alerting..receiver_authentication_bearer.authentication_scheme` | string (without spaces) | `Bearer` | | Prefix for Authorization header for bearer authentication. | *basename of the custom template file in `helm/alerting_payload_templates` without file extension From 7957567ccd5753d23af7ebe3ed04e23650a3978e Mon Sep 17 00:00:00 2001 From: Andrea Date: Mon, 28 Feb 2022 22:53:17 +0100 Subject: [PATCH 10/22] Renamed authorization_prefix to authentication_scheme in the alert_schema --- connaisseur/res/alertconfig_schema.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/connaisseur/res/alertconfig_schema.json b/connaisseur/res/alertconfig_schema.json index 72d405d34..e13b50d2a 100644 --- a/connaisseur/res/alertconfig_schema.json +++ b/connaisseur/res/alertconfig_schema.json @@ -35,7 +35,7 @@ "password_env": { "type": "string" }, - "authorization_prefix": { + "authentication_scheme": { "type": "string" } }, @@ -51,7 +51,7 @@ {"required": ["token_env"]} ], "properties": { - "authorization_prefix": { + "authentication_scheme": { "type": "string" }, "token_file": { @@ -150,7 +150,7 @@ "password_env": { "type": "string" }, - "authorization_prefix": { + "authentication_scheme": { "type": "string" } }, @@ -166,7 +166,7 @@ {"required": ["token_env"]} ], "properties": { - "authorization_prefix": { + "authentication_scheme": { "type": "string" }, "token_file": { @@ -235,4 +235,4 @@ ] } } -} \ No newline at end of file +} From f96b9078e9f646355361fb49692f63cb4e568c0a Mon Sep 17 00:00:00 2001 From: Andrea Date: Mon, 28 Feb 2022 22:54:34 +0100 Subject: [PATCH 11/22] Testing authentication_scheme validation in alert. --- connaisseur/alert.py | 3 ++- tests/test_alert.py | 59 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/connaisseur/alert.py b/connaisseur/alert.py index e3a26d9cf..544ce5cb4 100644 --- a/connaisseur/alert.py +++ b/connaisseur/alert.py @@ -90,6 +90,7 @@ class AlertReceiverNoneAuthentication(AlertReceiverAuthenticationInterface): """ Placeholder class for AlertReceiver without authentication. """ + def __init__(self, alert_receiver_config: dict): pass @@ -141,7 +142,7 @@ class AlertReceiverBearerAuthentication(AlertReceiverAuthenticationInterface): def __init__(self, alert_receiver_config: dict): super().__init__(alert_receiver_config, "receiver_authentication_bearer") - + token_env = self.authentication_config.get("token_env") token_file = self.authentication_config.get("token_file") diff --git a/tests/test_alert.py b/tests/test_alert.py index 3bd54ad19..db006e823 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -67,6 +67,16 @@ "template": "slack", } +receiver_config_bearer_env_invalid_scheme = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "CONNAISSEUR_ALERTING_TOKEN", + "authentication_scheme": "", + }, + "template": "slack", +} + receiver_config_bearer_file = { "receiver_url": "this.is.a.testurl.conn", "receiver_authentication_type": "bearer", @@ -76,12 +86,12 @@ "template": "slack", } -receiver_config_bearer_env_prefix = { +receiver_config_bearer_env_scheme = { "receiver_url": "this.is.a.testurl.conn", "receiver_authentication_type": "bearer", "receiver_authentication_bearer": { "token_env": "CONNAISSEUR_ALERTING_TOKEN", - "authorization_prefix": "Newprefix", + "authentication_scheme": "Newscheme", }, "template": "slack", } @@ -96,13 +106,24 @@ "template": "slack", } -receiver_config_basic_prefix = { +receiver_config_basic_invalid_scheme = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "CONNAISSEUR_ALERTING_USERNAME", + "password_env": "CONNAISSEUR_ALERTING_PASSWORD", + "authentication_scheme": "Ba sic", + }, + "template": "slack", +} + +receiver_config_basic_scheme = { "receiver_url": "this.is.a.testurl.conn", "receiver_authentication_type": "basic", "receiver_authentication_basic": { "username_env": "CONNAISSEUR_ALERTING_USERNAME", "password_env": "CONNAISSEUR_ALERTING_PASSWORD", - "authorization_prefix": "Newprefix", + "authentication_scheme": "Newscheme", }, "template": "slack", } @@ -372,11 +393,11 @@ def test_alert_init( fix.no_exc(), ), ( - receiver_config_bearer_env_prefix, + receiver_config_bearer_env_scheme, {"CONNAISSEUR_ALERTING_TOKEN": "AAABBBCCCDDD"}, {}, 2, - {"Authorization": "Newprefix AAABBBCCCDDD"}, + {"Authorization": "Newscheme AAABBBCCCDDD"}, fix.no_exc(), ), ( @@ -398,6 +419,17 @@ def test_alert_init( match=r"No token found from environmental variable.*", ), ), + ( + receiver_config_bearer_env_invalid_scheme, + {}, + {}, + 1, + {}, + pytest.raises( + ConfigurationError, + match=r"The authentication scheme cannot be null or empty.", + ), + ), ( receiver_config_bearer_file, {}, @@ -418,14 +450,14 @@ def test_alert_init( fix.no_exc(), ), ( - receiver_config_basic_prefix, + receiver_config_basic_scheme, { "CONNAISSEUR_ALERTING_USERNAME": "user", "CONNAISSEUR_ALERTING_PASSWORD": "password", }, {}, 2, - {"Authorization": "Newprefix user:password"}, + {"Authorization": "Newscheme user:password"}, fix.no_exc(), ), ( @@ -439,6 +471,17 @@ def test_alert_init( match=r"No username or password found from environmental variables.*", ), ), + ( + receiver_config_basic_invalid_scheme, + {}, + {}, + 1, + {}, + pytest.raises( + ConfigurationError, + match=r"The authentication scheme cannot contain any space.", + ), + ), ], ) def test_alert_init_auth( From be8c718d16c830084bd3ebdefc2b56589d9d3ddd Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 24 Feb 2022 18:45:42 +0100 Subject: [PATCH 12/22] added support for authentication in the alert (both basic and bearer). --- connaisseur/alert.py | 171 +++++++++++++++++++++++- connaisseur/res/alertconfig_schema.json | 148 +++++++++++++++++++- 2 files changed, 316 insertions(+), 3 deletions(-) diff --git a/connaisseur/alert.py b/connaisseur/alert.py index a8fdef4bf..f73117018 100644 --- a/connaisseur/alert.py +++ b/connaisseur/alert.py @@ -48,6 +48,166 @@ def alerting_required(self, event_category: str) -> bool: return bool(self.config.get(event_category)) +class AlertReceiverAuthentication: + """ + Class to store authentication information for securely sending events to the alert receiver. + """ + + class AlertReceiverBasicAuthentication: + """ + Class to store authentication information for basic authentication type with username and password. + """ + username: str + password: str + authentication_type: str + authorization_prefix: str = "Basic" + + def __init__(self, alert_receiver_config: dict): + basic_authentication_config = alert_receiver_config.get( + "receiver_authentication_basic", None + ) + + if ( + basic_authentication_config is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError("No basic authentication configuration found.") + + username_env = basic_authentication_config.get("username_env", None) + password_env = basic_authentication_config.get("password_env", None) + + if ( + username_env is None or password_env is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "No username_env or password_env configuration found." + ) + + self.username = os.environ.get(username_env, None) + self.password = os.environ.get(password_env, None) + + if self.username is None or self.password is None: + raise ConfigurationError( + f"No username or password found from environmental variables {username_env} and {password_env}." + ) + + self.authorization_prefix = basic_authentication_config.get( + "authorization_prefix", "Basic" + ) + # TODO maybe validate authorization prefix + + def get_header(self) -> dict: + return { + "Authorization": f"{self.authorization_prefix} {self.username}:{self.password}" + } + + class AlertReceiverBearerAuthentication: + """ + Class to store authentication information for bearer authentication type which uses a token. + """ + token: str + authorization_prefix: str = "Bearer" # default is bearer + + def __init__(self, alert_receiver_config: dict): + bearer_authentication_config = alert_receiver_config.get( + "receiver_authentication_bearer", None + ) + + if ( + bearer_authentication_config is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "No bearer authentication configuration found." + ) + + token_env = bearer_authentication_config.get("token_env", None) + token_file = bearer_authentication_config.get("token_file", None) + + if ( + token_env is None and token_file is None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "No token_env and token_file configuration found." + ) + + if ( + token_env is not None and token_file is not None + ): # TODO maybe remove this check since it is included in the json validation? + raise ConfigurationError( + "Both token_env and token_file configuration found. Only one is required." + ) + + if token_env is not None: + self.token = os.environ.get(token_env, None) + + if self.token is None: + raise ConfigurationError( + f"No token found from environmental variable {token_env}." + ) + else: + try: + with open(token_file, "r") as token_file: + self.token = token_file.read() + except FileNotFoundError: + raise ConfigurationError(f"No token file found at {token_file}.") + except Exception as err: + raise ConfigurationError( + f"An error occurred while loading the token file {token_file}: {str(err)}" + ) + + self.authorization_prefix = bearer_authentication_config.get( + "authorization_prefix", "Bearer" + ) + # TODO maybe validate authorization prefix + + def get_header(self) -> dict: + return {"Authorization": f"{self.authorization_prefix} {self.token}"} + + def __init__(self, alert_receiver_config: dict): + self.authentication_type = alert_receiver_config.get( + "receiver_authentication_type", "none" + ) + + if self.is_basic(): + self.__init_basic_authentication(alert_receiver_config) + elif self.is_bearer(): + self.__init_bearer_authentication(alert_receiver_config) + + def is_basic(self): + return self.authentication_type == "basic" + + def is_bearer(self): + return self.authentication_type == "bearer" + + def is_none(self): + return self.authentication_type == "none" + + def __init_bearer_authentication(self, alert_receiver_config: dict): + self.bearer_authentication = ( + AlertReceiverAuthentication.AlertReceiverBearerAuthentication( + alert_receiver_config + ) + ) + + def __init_basic_authentication(self, alert_receiver_config: dict): + self.basic_authentication = ( + AlertReceiverAuthentication.AlertReceiverBasicAuthentication( + alert_receiver_config + ) + ) + + def get_auth_header(self) -> dict: + if self.is_basic(): + return self.basic_authentication.get_header() + elif self.is_bearer(): + return self.bearer_authentication.get_header() + elif self.is_none(): + return {} + else: + raise ConfigurationError( + "No authentication type found." + ) # hopefully this never happens + + class Alert: """ Class to store image information about an alert as attributes and a sending @@ -59,6 +219,7 @@ class Alert: template: str receiver_url: str + receiver_authentication: AlertReceiverAuthentication payload: str headers: dict @@ -101,12 +262,13 @@ def __init__( "images": images, } self.receiver_url = receiver_config["receiver_url"] + self.receiver_authentication = AlertReceiverAuthentication(receiver_config) self.template = receiver_config["template"] self.throw_if_alert_sending_fails = receiver_config.get( "fail_if_alert_sending_fails", False ) self.payload = self.__construct_payload(receiver_config) - self.headers = self.__get_headers(receiver_config) + self.headers = self.__get_headers(receiver_config, self.receiver_authentication) def __construct_payload(self, receiver_config: dict) -> str: try: @@ -153,13 +315,18 @@ def send_alert(self) -> Optional[requests.Response]: return response @staticmethod - def __get_headers(receiver_config): + def __get_headers( + receiver_config: dict, receiver_authentication: AlertReceiverAuthentication + ) -> dict: headers = {"Content-Type": "application/json"} additional_headers = receiver_config.get("custom_headers") if additional_headers is not None: for header in additional_headers: key, value = header.split(":", 1) headers.update({key.strip(): value.strip()}) + auth_header = receiver_authentication.get_auth_header() + if auth_header: # not None and not empty + headers.update(auth_header) return headers diff --git a/connaisseur/res/alertconfig_schema.json b/connaisseur/res/alertconfig_schema.json index 8a894b2b1..72d405d34 100644 --- a/connaisseur/res/alertconfig_schema.json +++ b/connaisseur/res/alertconfig_schema.json @@ -18,6 +18,50 @@ "receiver_url": { "type": "string" }, + "receiver_authentication_type": { + "type": "string", + "enum": [ + "none", + "basic", + "bearer" + ] + }, + "receiver_authentication_basic": { + "type": "object", + "properties": { + "username_env": { + "type": "string" + }, + "password_env": { + "type": "string" + }, + "authorization_prefix": { + "type": "string" + } + }, + "required": [ + "username_env", + "password_env" + ] + }, + "receiver_authentication_bearer": { + "type": "object", + "oneOf": [ + {"required": ["token_file"]}, + {"required": ["token_env"]} + ], + "properties": { + "authorization_prefix": { + "type": "string" + }, + "token_file": { + "type": "string" + }, + "token_env": { + "type": "string" + } + } + }, "priority": { "type": "integer" }, @@ -35,6 +79,35 @@ "type": "boolean" } }, + "anyOf": [ + { + "properties": { + "receiver_authentication_type": { + "const": "basic" + } + }, + "required": [ + "receiver_authentication_basic" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "bearer" + } + }, + "required": [ + "receiver_authentication_bearer" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "none" + } + } + } + ], "required": [ "template", "receiver_url" @@ -60,6 +133,50 @@ "receiver_url": { "type": "string" }, + "receiver_authentication_type": { + "type": "string", + "enum": [ + "none", + "basic", + "bearer" + ] + }, + "receiver_authentication_basic": { + "type": "object", + "properties": { + "username_env": { + "type": "string" + }, + "password_env": { + "type": "string" + }, + "authorization_prefix": { + "type": "string" + } + }, + "required": [ + "username_env", + "password_env" + ] + }, + "receiver_authentication_bearer": { + "type": "object", + "oneOf": [ + {"required": ["token_file"]}, + {"required": ["token_env"]} + ], + "properties": { + "authorization_prefix": { + "type": "string" + }, + "token_file": { + "type": "string" + }, + "token_env": { + "type": "string" + } + } + }, "priority": { "type": "integer" }, @@ -77,6 +194,35 @@ "type": "boolean" } }, + "anyOf": [ + { + "properties": { + "receiver_authentication_type": { + "const": "basic" + } + }, + "required": [ + "receiver_authentication_basic" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "bearer" + } + }, + "required": [ + "receiver_authentication_bearer" + ] + }, + { + "properties": { + "receiver_authentication_type": { + "const": "none" + } + } + } + ], "required": [ "template", "receiver_url" @@ -89,4 +235,4 @@ ] } } -} +} \ No newline at end of file From cec1a0f23b2dc75bbbdf87a59c4177b762b86e5a Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 24 Feb 2022 18:47:26 +0100 Subject: [PATCH 13/22] added unit tests for alert authentication --- .../alertconfig_basic_1.json | 13 ++ .../alertconfig_basic_2.json | 16 ++ .../alertconfig_basic_3.json | 13 ++ .../alertconfig_basic_4.json | 16 ++ .../alertconfig_bearer_1.json | 17 ++ .../alertconfig_bearer_2.json | 17 ++ .../alertconfig_bearer_3.json | 17 ++ .../alertconfig_bearer_4.json | 17 ++ .../valid_config/alertconfig_basic_1.json | 32 +++ .../valid_config/alertconfig_bearer_1.json | 30 +++ tests/test_alert.py | 220 ++++++++++++++++++ 11 files changed, 408 insertions(+) create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_1.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_2.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_3.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_basic_4.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json create mode 100644 tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json create mode 100644 tests/data/alerting/valid_config/alertconfig_basic_1.json create mode 100644 tests/data/alerting/valid_config/alertconfig_bearer_1.json diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_1.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_1.json new file mode 100644 index 000000000..9421486d8 --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_1.json @@ -0,0 +1,13 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic" + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_2.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_2.json new file mode 100644 index 000000000..cadb6bbf1 --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_2.json @@ -0,0 +1,16 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username": "" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_3.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_3.json new file mode 100644 index 000000000..3a8e95b2b --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_3.json @@ -0,0 +1,13 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic" + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_basic_4.json b/tests/data/alerting/misconfigured_config/alertconfig_basic_4.json new file mode 100644 index 000000000..8178a785a --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_basic_4.json @@ -0,0 +1,16 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username": "" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json new file mode 100644 index 000000000..4f5d5877a --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json @@ -0,0 +1,17 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "123", + "token_file": "123" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json new file mode 100644 index 000000000..08b83eb78 --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json @@ -0,0 +1,17 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_basic": { + "username": "", + "password": "" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json new file mode 100644 index 000000000..6516904fb --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json @@ -0,0 +1,17 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "123", + "token_file": "123" + } + } + ] + } +} diff --git a/tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json b/tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json new file mode 100644 index 000000000..12d56eeec --- /dev/null +++ b/tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json @@ -0,0 +1,17 @@ +{ + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_basic": { + "username": "", + "password": "" + } + } + ] + } +} diff --git a/tests/data/alerting/valid_config/alertconfig_basic_1.json b/tests/data/alerting/valid_config/alertconfig_basic_1.json new file mode 100644 index 000000000..0751fe4c6 --- /dev/null +++ b/tests/data/alerting/valid_config/alertconfig_basic_1.json @@ -0,0 +1,32 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "", + "password_env": "" + } + } + ] + }, + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "", + "password_env": "" + } + } + ] + } +} diff --git a/tests/data/alerting/valid_config/alertconfig_bearer_1.json b/tests/data/alerting/valid_config/alertconfig_bearer_1.json new file mode 100644 index 000000000..44ae7fba2 --- /dev/null +++ b/tests/data/alerting/valid_config/alertconfig_bearer_1.json @@ -0,0 +1,30 @@ +{ + "reject_request": { + "message": "CONNAISSEUR rejected a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "" + } + } + ] + }, + "admit_request": { + "message": "CONNAISSEUR admitted a request", + "templates": [ + { + "priority": 3, + "receiver_url": "https://hooks.slack.com/services/123", + "template": "slack", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_file": "" + } + } + ] + } +} diff --git a/tests/test_alert.py b/tests/test_alert.py index e3ec661aa..3bd54ad19 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -1,3 +1,4 @@ +import os import pytest from datetime import datetime, timedelta import json @@ -57,6 +58,55 @@ "template": "custom", } +receiver_config_bearer_env = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "CONNAISSEUR_ALERTING_TOKEN", + }, + "template": "slack", +} + +receiver_config_bearer_file = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_file": "/tmp/token123456", + }, + "template": "slack", +} + +receiver_config_bearer_env_prefix = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "CONNAISSEUR_ALERTING_TOKEN", + "authorization_prefix": "Newprefix", + }, + "template": "slack", +} + +receiver_config_basic = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "CONNAISSEUR_ALERTING_USERNAME", + "password_env": "CONNAISSEUR_ALERTING_PASSWORD", + }, + "template": "slack", +} + +receiver_config_basic_prefix = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "CONNAISSEUR_ALERTING_USERNAME", + "password_env": "CONNAISSEUR_ALERTING_PASSWORD", + "authorization_prefix": "Newprefix", + }, + "template": "slack", +} + keybase_receiver_config = { "custom_headers": ["Content-Language: de-DE"], "fail_if_alert_sending_fails": True, @@ -129,6 +179,57 @@ pytest.raises(ConfigurationError, match=r".*invalid format.*"), ), ("/", True, pytest.raises(ConfigurationError, match=r".*error occurred.*")), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_1.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_2.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_3.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_bearer_4.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_1.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_2.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_3.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/misconfigured_config/alertconfig_basic_4.json", + True, + pytest.raises(ConfigurationError, match=r".*invalid format.*"), + ), + ( + "tests/data/alerting/valid_config/alertconfig_bearer_1.json", + False, + fix.no_exc(), + ), + ( + "tests/data/alerting/valid_config/alertconfig_basic_1.json", + False, + fix.no_exc(), + ), + ("tests/data/alerting/missing.json", True, fix.no_exc()), ], ) def test_alert_config_init( @@ -259,6 +360,125 @@ def test_alert_init( assert alert_payload == json.loads(alert_.payload) +@pytest.mark.parametrize( + "receiver_config, envs, files, headers_count, header, exception", + [ + ( + receiver_config_bearer_env, + {"CONNAISSEUR_ALERTING_TOKEN": "AAABBBCCCDDD"}, + {}, + 2, + {"Authorization": "Bearer AAABBBCCCDDD"}, + fix.no_exc(), + ), + ( + receiver_config_bearer_env_prefix, + {"CONNAISSEUR_ALERTING_TOKEN": "AAABBBCCCDDD"}, + {}, + 2, + {"Authorization": "Newprefix AAABBBCCCDDD"}, + fix.no_exc(), + ), + ( + receiver_config_bearer_file, + {}, + {"/tmp/token123456": "AAABBBCCCDDDEEE"}, + 2, + {"Authorization": "Bearer AAABBBCCCDDDEEE"}, + fix.no_exc(), + ), + ( + receiver_config_bearer_env, + {"CONNAISSEUR_ALERTING_TOKEN": ""}, + {}, + 1, + {}, + pytest.raises( + ConfigurationError, + match=r"No token found from environmental variable.*", + ), + ), + ( + receiver_config_bearer_file, + {}, + {}, + 1, + {}, + pytest.raises(ConfigurationError, match=r"No token file found.*"), + ), + ( + receiver_config_basic, + { + "CONNAISSEUR_ALERTING_USERNAME": "user", + "CONNAISSEUR_ALERTING_PASSWORD": "password", + }, + {}, + 2, + {"Authorization": "Basic user:password"}, + fix.no_exc(), + ), + ( + receiver_config_basic_prefix, + { + "CONNAISSEUR_ALERTING_USERNAME": "user", + "CONNAISSEUR_ALERTING_PASSWORD": "password", + }, + {}, + 2, + {"Authorization": "Newprefix user:password"}, + fix.no_exc(), + ), + ( + receiver_config_basic, + {"CONNAISSEUR_ALERTING_USERNAME": "", "CONNAISSEUR_ALERTING_PASSWORD": ""}, + {}, + 1, + {}, + pytest.raises( + ConfigurationError, + match=r"No username or password found from environmental variables.*", + ), + ), + ], +) +def test_alert_init_auth( + monkeypatch, + m_ad_schema_path, + m_alerting_without_send, + receiver_config: dict, + envs: dict, + files: dict, + headers_count: int, + header: dict, + exception, +): + alert_message = "Alert Message" + admission_request = admission_request_deployment + + for env, value in envs.items(): + if value == "": + if env in os.environ: + del os.environ[env] + else: + os.environ[env] = envs[env] + # monkeypatch.setenv(os.environ[env], envs[env]) + + for filepath, content in files.items(): + with open(filepath, "w") as f: + f.write(content) + + with exception: + alert_ = alert.Alert( + alert_message, receiver_config, AdmissionRequest(admission_request) + ) + assert len(alert_.headers) == headers_count + assert header.items() <= alert_.headers.items() + # assert list(header.keys())[0] in alert_.headers.keys() + + for filepath in files: + os.remove(filepath) + + @pytest.mark.parametrize( "receiver_config, key, status, out, exception", [ From 8050102ae28b51fe8642c0cc01e53dddf622fd03 Mon Sep 17 00:00:00 2001 From: Andrea Date: Thu, 24 Feb 2022 18:52:34 +0100 Subject: [PATCH 14/22] updated documentation for alert authentication --- docs/features/alerting.md | 75 ++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/docs/features/alerting.md b/docs/features/alerting.md index cb08b6748..b45642fe0 100644 --- a/docs/features/alerting.md +++ b/docs/features/alerting.md @@ -16,15 +16,24 @@ alerts at the same time. Currently, Connaisseur supports alerting on either admittance of images, denial of images or both. These event categories can be configured independently of each other under the relevant category (i.e. `admit_request` or `reject_request`): -| Key | Accepted values | Default | Required | Description | -| -------------------------------------------------- | ---------------------------------------------------- | ----------------- | ------------------ | -------------------------------------------------------------------------------------------------- | -| `alerting.cluster_identifier` | string | `"not specified"` | | Cluster identifier used in alert payload to distinguish between alerts from different clusters. | -| `alerting..template` | `opsgenie`, `slack`, `keybase`, `ecs-1-12-0` or custom* | - | :heavy_check_mark: | File in `helm/alert_payload_templates/` to be used as alert payload template. | -| `alerting..receiver_url` | string | - | :heavy_check_mark: | URL of alert-receiving endpoint. | -| `alerting..priority` | int | `3` | | Priority of alert (to enable fitting Connaisseur alerts into alerts from other sources). | -| `alerting..custom_headers` | list[string] | - | | Additional headers required by alert-receiving endpoint. | -| `alerting..payload_fields` | subyaml | - | | Additional (`yaml`) key-value pairs to be appended to alert payload (as `json`). | -| `alerting..fail_if_alert_sending_fails` | bool | `False` | | Whether to make Connaisseur deny images if the corresponding alert cannot be successfully sent. | +| Key | Accepted values | Default | Required | Description | +| --------------------------------------------------- | ----------------------------------------------------- | ----------------- | ------------------ | --------------------------------------------------------------------------------------------------- | +| `alerting.cluster_identifier` | string | `"not specified"` | | Cluster identifier used in alert payload to distinguish between alerts from different clusters. | +| `alerting..template` | `opsgenie`, `slack`, `keybase`, `ecs-1-12-0` or custom* | - | :heavy_check_mark: | File in `helm/alert_payload_templates/` to be used as alert payload template. | +| `alerting..receiver_url` | string | - | :heavy_check_mark: | URL of alert-receiving endpoint. | +| `alerting..priority` | int | `3` | | Priority of alert (to enable fitting Connaisseur alerts into alerts from other sources). | +| `alerting..custom_headers` | list[string] | - | | Additional headers required by alert-receiving endpoint. | +| `alerting..payload_fields` | subyaml | - | | Additional (`yaml`) key-value pairs to be appended to alert payload (as `json`). | +| `alerting..fail_if_alert_sending_fails` | bool | `False` | | Whether to make Connaisseur deny images if the corresponding alert cannot be successfully sent. | +| `alerting..receiver_authentication_type` | string enum `basic`, `bearer`, `none` | `none` | | Authentication type of the alert-receiving webhook endpoint . | +| `alerting..receiver_authentication_basic` | object | - | only when `receiver_authentication_type` is `basic` | Authentication credentials for basic authentication. | +| `alerting..receiver_authentication_basic.username_env` | string | - | only when `receiver_authentication_type` is `basic` | Username Environmental variable for basic authentication. | +| `alerting..receiver_authentication_basic.password_env` | string | - | only when `receiver_authentication_type` is `basic` | Password Environmental variable for basic authentication. | +| `alerting..receiver_authentication_basic.authorization_prefix` | string | `Basic` | | Prefix for Authorization header for basic authentication. | +| `alerting..receiver_authentication_bearer` | object | - | only when `receiver_authentication_type` is `bearer` | Authentication credentials for bearer authentication. | +| `alerting..receiver_authentication_bearer.token_env` | string | - | only when `receiver_authentication_type` is `bearer` | Token Environmental variable for bearer authentication (Exclusive with `token_file`). | +| `alerting..receiver_authentication_bearer.token_file` | string | - | only when `receiver_authentication_type` is `bearer` | Token file for bearer authentication (Exclusive with `token_env`). | +| `alerting..receiver_authentication_bearer.authorization_prefix` | string | `Bearer` | | Prefix for Authorization header for bearer authentication. | *basename of the custom template file in `helm/alerting_payload_templates` without file extension @@ -52,6 +61,54 @@ alerting: receiver_url: https://bots.keybase.io/webhookbot/ ``` +## Example With Authentication + +For example, if you would like to receive notifications in your custom webhook authenticated with a bearer token taken from an environmental variable whenever Connaisseur admits a request to your cluster, your alerting configuration would look similar to the following snippet: + +``` +alerting: + admit_request: + templates: + - template: ecs-1-12-0 + receiver_url: https://your.custom.domain.com/webhook/admit + receiver_authentication_type: bearer + receiver_authentication_bearer: + token_env: CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_TOKEN +``` + +You then have to set the `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_TOKEN` environment variable referencing the bearer token secret you want to use into the connaisseur deployment. + +Or if you would like to use the service account token as the bearer token, you can use the following snippet: + +``` +alerting: + admit_request: + templates: + - template: ecs-1-12-0 + receiver_url: https://your.custom.domain.com/webhook/admit + receiver_authentication_type: bearer + receiver_authentication_bearer: + token_file: /var/run/secrets/kubernetes.io/serviceaccount/token +``` + +Finally in case of basic authentication, you can use the following snippet: + + +``` +alerting: + admit_request: + templates: + - template: ecs-1-12-0 + receiver_url: https://your.custom.domain.com/webhook/admit + receiver_authentication_type: basic + receiver_authentication_basic: + username_env: CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_USERNAME + password_env: CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_PASSWORD +``` + +You then have to set the `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_USERNAME` and `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_PASSWORD` environment variables referencing the secret you want to use into the connaisseur deployment. + + ## Additional notes ### Creating a custom template From c04c9029b5c0ffa5b9dd83d9285be4990e5cba4a Mon Sep 17 00:00:00 2001 From: Andrea Date: Mon, 28 Feb 2022 22:50:28 +0100 Subject: [PATCH 15/22] Improved alert class accordingly to the pull request --- connaisseur/alert.py | 160 ++++++++++++++++++++++--------------------- 1 file changed, 81 insertions(+), 79 deletions(-) diff --git a/connaisseur/alert.py b/connaisseur/alert.py index f73117018..e3a26d9cf 100644 --- a/connaisseur/alert.py +++ b/connaisseur/alert.py @@ -1,3 +1,4 @@ +from abc import abstractmethod import json import logging import os @@ -53,31 +54,66 @@ class AlertReceiverAuthentication: Class to store authentication information for securely sending events to the alert receiver. """ - class AlertReceiverBasicAuthentication: + authentication_config: dict = None + authentication_scheme: str = None + + class AlertReceiverAuthenticationInterface: + def __init__(self, alert_receiver_config: dict, authentication_key: str): + self.authentication_config = alert_receiver_config.get(authentication_key) + + if self.authentication_config is None: + raise ConfigurationError( + f"No authentication configuration found ({authentication_key})." + ) + + self.authentication_scheme = self.authentication_config.get( + "authentication_scheme", self.authentication_scheme + ) + self._validate_authentication_scheme() + + def _validate_authentication_scheme(self) -> None: + if not self.authentication_scheme: + raise ConfigurationError( + "The authentication scheme cannot be null or empty." + ) + + if " " in self.authentication_scheme: + raise ConfigurationError( + "The authentication scheme cannot contain any space." + ) + + @abstractmethod + def get_header(self) -> dict: + pass + + class AlertReceiverNoneAuthentication(AlertReceiverAuthenticationInterface): + """ + Placeholder class for AlertReceiver without authentication. + """ + def __init__(self, alert_receiver_config: dict): + pass + + def get_header(self) -> dict: + return {} + + class AlertReceiverBasicAuthentication(AlertReceiverAuthenticationInterface): """ Class to store authentication information for basic authentication type with username and password. """ + username: str password: str - authentication_type: str - authorization_prefix: str = "Basic" + authentication_scheme: str = "Basic" def __init__(self, alert_receiver_config: dict): - basic_authentication_config = alert_receiver_config.get( - "receiver_authentication_basic", None - ) - - if ( - basic_authentication_config is None - ): # TODO maybe remove this check since it is included in the json validation? - raise ConfigurationError("No basic authentication configuration found.") + super().__init__(alert_receiver_config, "receiver_authentication_basic") - username_env = basic_authentication_config.get("username_env", None) - password_env = basic_authentication_config.get("password_env", None) + username_env = self.authentication_config.get("username_env") + password_env = self.authentication_config.get("password_env") if ( username_env is None or password_env is None - ): # TODO maybe remove this check since it is included in the json validation? + ): # This should not happen since it is included in the json validation raise ConfigurationError( "No username_env or password_env configuration found." ) @@ -90,50 +126,37 @@ def __init__(self, alert_receiver_config: dict): f"No username or password found from environmental variables {username_env} and {password_env}." ) - self.authorization_prefix = basic_authentication_config.get( - "authorization_prefix", "Basic" - ) - # TODO maybe validate authorization prefix - def get_header(self) -> dict: return { - "Authorization": f"{self.authorization_prefix} {self.username}:{self.password}" + "Authorization": f"{self.authentication_scheme} {self.username}:{self.password}" } - class AlertReceiverBearerAuthentication: + class AlertReceiverBearerAuthentication(AlertReceiverAuthenticationInterface): """ Class to store authentication information for bearer authentication type which uses a token. """ + token: str - authorization_prefix: str = "Bearer" # default is bearer + authentication_scheme: str = "Bearer" # default is bearer def __init__(self, alert_receiver_config: dict): - bearer_authentication_config = alert_receiver_config.get( - "receiver_authentication_bearer", None - ) - - if ( - bearer_authentication_config is None - ): # TODO maybe remove this check since it is included in the json validation? - raise ConfigurationError( - "No bearer authentication configuration found." - ) - - token_env = bearer_authentication_config.get("token_env", None) - token_file = bearer_authentication_config.get("token_file", None) + super().__init__(alert_receiver_config, "receiver_authentication_bearer") + + token_env = self.authentication_config.get("token_env") + token_file = self.authentication_config.get("token_file") if ( token_env is None and token_file is None - ): # TODO maybe remove this check since it is included in the json validation? + ): # This should not happen since it is included in the json validation raise ConfigurationError( "No token_env and token_file configuration found." ) if ( token_env is not None and token_file is not None - ): # TODO maybe remove this check since it is included in the json validation? + ): # This should not happen since it is included in the json validation raise ConfigurationError( - "Both token_env and token_file configuration found. Only one is required." + "Both token_env and token_file configuration found. Only one can be given." ) if token_env is not None: @@ -154,58 +177,37 @@ def __init__(self, alert_receiver_config: dict): f"An error occurred while loading the token file {token_file}: {str(err)}" ) - self.authorization_prefix = bearer_authentication_config.get( - "authorization_prefix", "Bearer" - ) - # TODO maybe validate authorization prefix - def get_header(self) -> dict: - return {"Authorization": f"{self.authorization_prefix} {self.token}"} + return {"Authorization": f"{self.authentication_scheme} {self.token}"} + + init_map = { + "basic": AlertReceiverBasicAuthentication, + "bearer": AlertReceiverBearerAuthentication, + "none": AlertReceiverNoneAuthentication, + } + + _authentication_instance = None def __init__(self, alert_receiver_config: dict): self.authentication_type = alert_receiver_config.get( "receiver_authentication_type", "none" ) + self.__init_authentication_instance(alert_receiver_config) - if self.is_basic(): - self.__init_basic_authentication(alert_receiver_config) - elif self.is_bearer(): - self.__init_bearer_authentication(alert_receiver_config) + def __init_authentication_instance(self, alert_receiver_config: dict): + authentication_class = self.__get_authentication_class() + self._authentication_instance = authentication_class(alert_receiver_config) - def is_basic(self): - return self.authentication_type == "basic" - - def is_bearer(self): - return self.authentication_type == "bearer" + def __get_authentication_class(self): + if self.authentication_type not in AlertReceiverAuthentication.init_map.keys(): + raise ConfigurationError( + f"No authentication type found. Valid values are {list(AlertReceiverAuthentication.init_map.keys())}" + ) # hopefully this never happens - def is_none(self): - return self.authentication_type == "none" - - def __init_bearer_authentication(self, alert_receiver_config: dict): - self.bearer_authentication = ( - AlertReceiverAuthentication.AlertReceiverBearerAuthentication( - alert_receiver_config - ) - ) - - def __init_basic_authentication(self, alert_receiver_config: dict): - self.basic_authentication = ( - AlertReceiverAuthentication.AlertReceiverBasicAuthentication( - alert_receiver_config - ) - ) + return self.init_map.get(self.authentication_type) def get_auth_header(self) -> dict: - if self.is_basic(): - return self.basic_authentication.get_header() - elif self.is_bearer(): - return self.bearer_authentication.get_header() - elif self.is_none(): - return {} - else: - raise ConfigurationError( - "No authentication type found." - ) # hopefully this never happens + return self._authentication_instance.get_header() class Alert: From d98c6bd688cdde3f2d365bf32d2d06a75fcd3cc3 Mon Sep 17 00:00:00 2001 From: Andrea Date: Mon, 28 Feb 2022 22:52:41 +0100 Subject: [PATCH 16/22] Updated alert documentation --- docs/features/alerting.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/alerting.md b/docs/features/alerting.md index b45642fe0..1ba423f49 100644 --- a/docs/features/alerting.md +++ b/docs/features/alerting.md @@ -29,11 +29,11 @@ Currently, Connaisseur supports alerting on either admittance of images, denial | `alerting..receiver_authentication_basic` | object | - | only when `receiver_authentication_type` is `basic` | Authentication credentials for basic authentication. | | `alerting..receiver_authentication_basic.username_env` | string | - | only when `receiver_authentication_type` is `basic` | Username Environmental variable for basic authentication. | | `alerting..receiver_authentication_basic.password_env` | string | - | only when `receiver_authentication_type` is `basic` | Password Environmental variable for basic authentication. | -| `alerting..receiver_authentication_basic.authorization_prefix` | string | `Basic` | | Prefix for Authorization header for basic authentication. | +| `alerting..receiver_authentication_basic.authentication_scheme` | string (without spaces) | `Basic` | | Prefix for Authorization header for basic authentication. | | `alerting..receiver_authentication_bearer` | object | - | only when `receiver_authentication_type` is `bearer` | Authentication credentials for bearer authentication. | | `alerting..receiver_authentication_bearer.token_env` | string | - | only when `receiver_authentication_type` is `bearer` | Token Environmental variable for bearer authentication (Exclusive with `token_file`). | | `alerting..receiver_authentication_bearer.token_file` | string | - | only when `receiver_authentication_type` is `bearer` | Token file for bearer authentication (Exclusive with `token_env`). | -| `alerting..receiver_authentication_bearer.authorization_prefix` | string | `Bearer` | | Prefix for Authorization header for bearer authentication. | +| `alerting..receiver_authentication_bearer.authentication_scheme` | string (without spaces) | `Bearer` | | Prefix for Authorization header for bearer authentication. | *basename of the custom template file in `helm/alerting_payload_templates` without file extension From a34506d68720f7166f8d7716ccc5c2f99266fb62 Mon Sep 17 00:00:00 2001 From: Andrea Date: Mon, 28 Feb 2022 22:53:17 +0100 Subject: [PATCH 17/22] Renamed authorization_prefix to authentication_scheme in the alert_schema --- connaisseur/res/alertconfig_schema.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/connaisseur/res/alertconfig_schema.json b/connaisseur/res/alertconfig_schema.json index 72d405d34..e13b50d2a 100644 --- a/connaisseur/res/alertconfig_schema.json +++ b/connaisseur/res/alertconfig_schema.json @@ -35,7 +35,7 @@ "password_env": { "type": "string" }, - "authorization_prefix": { + "authentication_scheme": { "type": "string" } }, @@ -51,7 +51,7 @@ {"required": ["token_env"]} ], "properties": { - "authorization_prefix": { + "authentication_scheme": { "type": "string" }, "token_file": { @@ -150,7 +150,7 @@ "password_env": { "type": "string" }, - "authorization_prefix": { + "authentication_scheme": { "type": "string" } }, @@ -166,7 +166,7 @@ {"required": ["token_env"]} ], "properties": { - "authorization_prefix": { + "authentication_scheme": { "type": "string" }, "token_file": { @@ -235,4 +235,4 @@ ] } } -} \ No newline at end of file +} From 14f58cd89815fbae23bb09e55118b03e1eb37560 Mon Sep 17 00:00:00 2001 From: Andrea Date: Mon, 28 Feb 2022 22:54:34 +0100 Subject: [PATCH 18/22] Testing authentication_scheme validation in alert. --- connaisseur/alert.py | 3 ++- tests/test_alert.py | 59 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/connaisseur/alert.py b/connaisseur/alert.py index e3a26d9cf..544ce5cb4 100644 --- a/connaisseur/alert.py +++ b/connaisseur/alert.py @@ -90,6 +90,7 @@ class AlertReceiverNoneAuthentication(AlertReceiverAuthenticationInterface): """ Placeholder class for AlertReceiver without authentication. """ + def __init__(self, alert_receiver_config: dict): pass @@ -141,7 +142,7 @@ class AlertReceiverBearerAuthentication(AlertReceiverAuthenticationInterface): def __init__(self, alert_receiver_config: dict): super().__init__(alert_receiver_config, "receiver_authentication_bearer") - + token_env = self.authentication_config.get("token_env") token_file = self.authentication_config.get("token_file") diff --git a/tests/test_alert.py b/tests/test_alert.py index 3bd54ad19..db006e823 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -67,6 +67,16 @@ "template": "slack", } +receiver_config_bearer_env_invalid_scheme = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "bearer", + "receiver_authentication_bearer": { + "token_env": "CONNAISSEUR_ALERTING_TOKEN", + "authentication_scheme": "", + }, + "template": "slack", +} + receiver_config_bearer_file = { "receiver_url": "this.is.a.testurl.conn", "receiver_authentication_type": "bearer", @@ -76,12 +86,12 @@ "template": "slack", } -receiver_config_bearer_env_prefix = { +receiver_config_bearer_env_scheme = { "receiver_url": "this.is.a.testurl.conn", "receiver_authentication_type": "bearer", "receiver_authentication_bearer": { "token_env": "CONNAISSEUR_ALERTING_TOKEN", - "authorization_prefix": "Newprefix", + "authentication_scheme": "Newscheme", }, "template": "slack", } @@ -96,13 +106,24 @@ "template": "slack", } -receiver_config_basic_prefix = { +receiver_config_basic_invalid_scheme = { + "receiver_url": "this.is.a.testurl.conn", + "receiver_authentication_type": "basic", + "receiver_authentication_basic": { + "username_env": "CONNAISSEUR_ALERTING_USERNAME", + "password_env": "CONNAISSEUR_ALERTING_PASSWORD", + "authentication_scheme": "Ba sic", + }, + "template": "slack", +} + +receiver_config_basic_scheme = { "receiver_url": "this.is.a.testurl.conn", "receiver_authentication_type": "basic", "receiver_authentication_basic": { "username_env": "CONNAISSEUR_ALERTING_USERNAME", "password_env": "CONNAISSEUR_ALERTING_PASSWORD", - "authorization_prefix": "Newprefix", + "authentication_scheme": "Newscheme", }, "template": "slack", } @@ -372,11 +393,11 @@ def test_alert_init( fix.no_exc(), ), ( - receiver_config_bearer_env_prefix, + receiver_config_bearer_env_scheme, {"CONNAISSEUR_ALERTING_TOKEN": "AAABBBCCCDDD"}, {}, 2, - {"Authorization": "Newprefix AAABBBCCCDDD"}, + {"Authorization": "Newscheme AAABBBCCCDDD"}, fix.no_exc(), ), ( @@ -398,6 +419,17 @@ def test_alert_init( match=r"No token found from environmental variable.*", ), ), + ( + receiver_config_bearer_env_invalid_scheme, + {}, + {}, + 1, + {}, + pytest.raises( + ConfigurationError, + match=r"The authentication scheme cannot be null or empty.", + ), + ), ( receiver_config_bearer_file, {}, @@ -418,14 +450,14 @@ def test_alert_init( fix.no_exc(), ), ( - receiver_config_basic_prefix, + receiver_config_basic_scheme, { "CONNAISSEUR_ALERTING_USERNAME": "user", "CONNAISSEUR_ALERTING_PASSWORD": "password", }, {}, 2, - {"Authorization": "Newprefix user:password"}, + {"Authorization": "Newscheme user:password"}, fix.no_exc(), ), ( @@ -439,6 +471,17 @@ def test_alert_init( match=r"No username or password found from environmental variables.*", ), ), + ( + receiver_config_basic_invalid_scheme, + {}, + {}, + 1, + {}, + pytest.raises( + ConfigurationError, + match=r"The authentication scheme cannot contain any space.", + ), + ), ], ) def test_alert_init_auth( From b3a54c078d77a07cfa48f2c4274a01e180cd7805 Mon Sep 17 00:00:00 2001 From: Andrea Date: Tue, 1 Mar 2022 18:57:15 +0100 Subject: [PATCH 19/22] Code updated to fix pylint suggestions, and other minor changes. --- connaisseur/alert.py | 72 +++++++++++++++++++++++--------------------- tests/test_alert.py | 2 +- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/connaisseur/alert.py b/connaisseur/alert.py index 544ce5cb4..1fdf6b58a 100644 --- a/connaisseur/alert.py +++ b/connaisseur/alert.py @@ -1,4 +1,3 @@ -from abc import abstractmethod import json import logging import os @@ -58,33 +57,36 @@ class AlertReceiverAuthentication: authentication_scheme: str = None class AlertReceiverAuthenticationInterface: - def __init__(self, alert_receiver_config: dict, authentication_key: str): - self.authentication_config = alert_receiver_config.get(authentication_key) - - if self.authentication_config is None: - raise ConfigurationError( - f"No authentication configuration found ({authentication_key})." + def __init__(self, alert_receiver_config: dict, authentication_config_key: str): + if authentication_config_key is not None: + self.authentication_config = alert_receiver_config.get( + authentication_config_key ) - self.authentication_scheme = self.authentication_config.get( - "authentication_scheme", self.authentication_scheme - ) - self._validate_authentication_scheme() + if self.authentication_config is None: + raise ConfigurationError( + "No authentication configuration found for dictionary key:" + f"{authentication_config_key}." + ) + + self.authentication_scheme = self.authentication_config.get( + "authentication_scheme", self.authentication_scheme + ) + self._validate_authentication_scheme() def _validate_authentication_scheme(self) -> None: if not self.authentication_scheme: raise ConfigurationError( "The authentication scheme cannot be null or empty." ) - - if " " in self.authentication_scheme: + # check if self.authentication_scheme contains only letters + if not self.authentication_scheme.isalpha(): raise ConfigurationError( - "The authentication scheme cannot contain any space." + "The authentication scheme must contain only letters." ) - @abstractmethod def get_header(self) -> dict: - pass + return {} class AlertReceiverNoneAuthentication(AlertReceiverAuthenticationInterface): """ @@ -92,14 +94,12 @@ class AlertReceiverNoneAuthentication(AlertReceiverAuthenticationInterface): """ def __init__(self, alert_receiver_config: dict): - pass - - def get_header(self) -> dict: - return {} + super().__init__(alert_receiver_config, None) class AlertReceiverBasicAuthentication(AlertReceiverAuthenticationInterface): """ - Class to store authentication information for basic authentication type with username and password. + Class to store authentication information for basic authentication type + with username and password. """ username: str @@ -124,7 +124,8 @@ def __init__(self, alert_receiver_config: dict): if self.username is None or self.password is None: raise ConfigurationError( - f"No username or password found from environmental variables {username_env} and {password_env}." + "No username or password found from environmental variables " + f"{username_env} and {password_env}." ) def get_header(self) -> dict: @@ -169,14 +170,16 @@ def __init__(self, alert_receiver_config: dict): ) else: try: - with open(token_file, "r") as token_file: + with open(token_file, "r", encoding="utf-8") as token_file: self.token = token_file.read() - except FileNotFoundError: - raise ConfigurationError(f"No token file found at {token_file}.") + except FileNotFoundError as err: + raise ConfigurationError( + f"No token file found at {token_file}." + ) from err except Exception as err: raise ConfigurationError( f"An error occurred while loading the token file {token_file}: {str(err)}" - ) + ) from err def get_header(self) -> dict: return {"Authorization": f"{self.authentication_scheme} {self.token}"} @@ -196,16 +199,15 @@ def __init__(self, alert_receiver_config: dict): self.__init_authentication_instance(alert_receiver_config) def __init_authentication_instance(self, alert_receiver_config: dict): - authentication_class = self.__get_authentication_class() - self._authentication_instance = authentication_class(alert_receiver_config) - - def __get_authentication_class(self): - if self.authentication_type not in AlertReceiverAuthentication.init_map.keys(): + try: + self._authentication_instance = self.init_map[self.authentication_type]( + alert_receiver_config + ) + except KeyError as err: raise ConfigurationError( - f"No authentication type found. Valid values are {list(AlertReceiverAuthentication.init_map.keys())}" - ) # hopefully this never happens - - return self.init_map.get(self.authentication_type) + "No authentication type found. Valid values are " + f"{list(AlertReceiverAuthentication.init_map.keys())}" + ) from err # hopefully this never happens def get_auth_header(self) -> dict: return self._authentication_instance.get_header() diff --git a/tests/test_alert.py b/tests/test_alert.py index db006e823..b8f08823e 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -479,7 +479,7 @@ def test_alert_init( {}, pytest.raises( ConfigurationError, - match=r"The authentication scheme cannot contain any space.", + match=r"The authentication scheme must contain only letters.", ), ), ], From 0f26f6dbb0fc2b162b61fb15744fdb8efb516010 Mon Sep 17 00:00:00 2001 From: Andrea Date: Wed, 2 Mar 2022 09:09:01 +0100 Subject: [PATCH 20/22] Embracing the EAFP paradigm. --- connaisseur/alert.py | 46 +++++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/connaisseur/alert.py b/connaisseur/alert.py index 1fdf6b58a..52aa109e9 100644 --- a/connaisseur/alert.py +++ b/connaisseur/alert.py @@ -59,15 +59,15 @@ class AlertReceiverAuthentication: class AlertReceiverAuthenticationInterface: def __init__(self, alert_receiver_config: dict, authentication_config_key: str): if authentication_config_key is not None: - self.authentication_config = alert_receiver_config.get( - authentication_config_key - ) - - if self.authentication_config is None: + try: + self.authentication_config = alert_receiver_config[ + authentication_config_key + ] + except KeyError as err: raise ConfigurationError( "No authentication configuration found for dictionary key:" f"{authentication_config_key}." - ) + ) from err self.authentication_scheme = self.authentication_config.get( "authentication_scheme", self.authentication_scheme @@ -109,24 +109,22 @@ class AlertReceiverBasicAuthentication(AlertReceiverAuthenticationInterface): def __init__(self, alert_receiver_config: dict): super().__init__(alert_receiver_config, "receiver_authentication_basic") - username_env = self.authentication_config.get("username_env") - password_env = self.authentication_config.get("password_env") - - if ( - username_env is None or password_env is None - ): # This should not happen since it is included in the json validation + try: + username_env = self.authentication_config["username_env"] + password_env = self.authentication_config["password_env"] + except KeyError as err: raise ConfigurationError( "No username_env or password_env configuration found." - ) + ) from err - self.username = os.environ.get(username_env, None) - self.password = os.environ.get(password_env, None) - - if self.username is None or self.password is None: + try: + self.username = os.environ[username_env] + self.password = os.environ[password_env] + except KeyError as err: raise ConfigurationError( "No username or password found from environmental variables " f"{username_env} and {password_env}." - ) + ) from err def get_header(self) -> dict: return { @@ -162,16 +160,16 @@ def __init__(self, alert_receiver_config: dict): ) if token_env is not None: - self.token = os.environ.get(token_env, None) - - if self.token is None: + try: + self.token = os.environ[token_env] + except KeyError as err: raise ConfigurationError( f"No token found from environmental variable {token_env}." - ) + ) from err else: try: - with open(token_file, "r", encoding="utf-8") as token_file: - self.token = token_file.read() + with open(token_file, "r", encoding="utf-8") as token_file_handler: + self.token = token_file_handler.read() except FileNotFoundError as err: raise ConfigurationError( f"No token file found at {token_file}." From 826c78e0de520993b67b3190355bf2153810a36c Mon Sep 17 00:00:00 2001 From: Andrea Date: Sat, 5 Mar 2022 19:14:41 +0100 Subject: [PATCH 21/22] Moving the alert webhook authentication documentation in the additional notes section. --- connaisseur/alert.py | 2 +- docs/features/alerting.md | 116 ++++++++++++++++++++++++-------------- 2 files changed, 75 insertions(+), 43 deletions(-) diff --git a/connaisseur/alert.py b/connaisseur/alert.py index 52aa109e9..1a7def4af 100644 --- a/connaisseur/alert.py +++ b/connaisseur/alert.py @@ -79,7 +79,7 @@ def _validate_authentication_scheme(self) -> None: raise ConfigurationError( "The authentication scheme cannot be null or empty." ) - # check if self.authentication_scheme contains only letters + if not self.authentication_scheme.isalpha(): raise ConfigurationError( "The authentication scheme must contain only letters." diff --git a/docs/features/alerting.md b/docs/features/alerting.md index 1ba423f49..0870049d5 100644 --- a/docs/features/alerting.md +++ b/docs/features/alerting.md @@ -25,15 +25,6 @@ Currently, Connaisseur supports alerting on either admittance of images, denial | `alerting..custom_headers` | list[string] | - | | Additional headers required by alert-receiving endpoint. | | `alerting..payload_fields` | subyaml | - | | Additional (`yaml`) key-value pairs to be appended to alert payload (as `json`). | | `alerting..fail_if_alert_sending_fails` | bool | `False` | | Whether to make Connaisseur deny images if the corresponding alert cannot be successfully sent. | -| `alerting..receiver_authentication_type` | string enum `basic`, `bearer`, `none` | `none` | | Authentication type of the alert-receiving webhook endpoint . | -| `alerting..receiver_authentication_basic` | object | - | only when `receiver_authentication_type` is `basic` | Authentication credentials for basic authentication. | -| `alerting..receiver_authentication_basic.username_env` | string | - | only when `receiver_authentication_type` is `basic` | Username Environmental variable for basic authentication. | -| `alerting..receiver_authentication_basic.password_env` | string | - | only when `receiver_authentication_type` is `basic` | Password Environmental variable for basic authentication. | -| `alerting..receiver_authentication_basic.authentication_scheme` | string (without spaces) | `Basic` | | Prefix for Authorization header for basic authentication. | -| `alerting..receiver_authentication_bearer` | object | - | only when `receiver_authentication_type` is `bearer` | Authentication credentials for bearer authentication. | -| `alerting..receiver_authentication_bearer.token_env` | string | - | only when `receiver_authentication_type` is `bearer` | Token Environmental variable for bearer authentication (Exclusive with `token_file`). | -| `alerting..receiver_authentication_bearer.token_file` | string | - | only when `receiver_authentication_type` is `bearer` | Token file for bearer authentication (Exclusive with `token_env`). | -| `alerting..receiver_authentication_bearer.authentication_scheme` | string (without spaces) | `Bearer` | | Prefix for Authorization header for bearer authentication. | *basename of the custom template file in `helm/alerting_payload_templates` without file extension @@ -53,7 +44,7 @@ one it needs to be one of `slack`, `keybase`, `opsgenie` or `ecs-1-12-0`. For example, if you would like to receive notifications in Keybase whenever Connaisseur admits a request to your cluster, your alerting configuration would look similar to the following snippet: -``` +```yaml alerting: admit_request: templates: @@ -61,11 +52,59 @@ alerting: receiver_url: https://bots.keybase.io/webhookbot/ ``` -## Example With Authentication + +## Additional notes + +### Creating a custom template + +Along the lines of the templates that already exist you can easily define +custom templates for other endpoints. The following variables can be rendered +during runtime into the payload: + +- `alert_message` +- `priority` +- `connaisseur_pod_id` +- `cluster` +- `timestamp` +- `request_id` +- `images` + +Referring to any of these variables in the templates works by Jinja2 notation +(e.g. `{{ timestamp }}`). You can update your payload dynamically by adding payload +fields in `yaml` representation in the `payload_fields` key which will be translated +to JSON by Helm as is. If your REST endpoint requires particular headers, you can +specify them as described above in `custom_headers`. + +Feel free to make a PR to share with the community if you add new neat templates for other third parties :pray: + + +### Webhook Authentication + + +#### Configuration options + +Currently, Connaisseur supports alerting on either admittance of images, denial of images or both. These event categories can be configured independently of each other under the relevant category (i.e. `admit_request` or `reject_request`): + +| Key | Accepted values | Default | Required | Description | +| --------------------------------------------------- | ----------------------------------------------------- | ----------------- | ------------------ | --------------------------------------------------------------------------------------------------- | +| `alerting..receiver_authentication_type` | string enum `basic`, `bearer`, `none` | `none` | | Authentication type of the alert-receiving webhook endpoint . | +| `alerting..receiver_authentication_basic` | object | - | only when `receiver_authentication_type` is `basic` | Authentication credentials for basic authentication. | +| `alerting..receiver_authentication_basic.username_env` | string | - | only when `receiver_authentication_type` is `basic` | Username Environmental variable for basic authentication. | +| `alerting..receiver_authentication_basic.password_env` | string | - | only when `receiver_authentication_type` is `basic` | Password Environmental variable for basic authentication. | +| `alerting..receiver_authentication_basic.authentication_scheme` | string (without spaces) | `Basic` | | Prefix for Authorization header for basic authentication. | +| `alerting..receiver_authentication_bearer` | object | - | only when `receiver_authentication_type` is `bearer` | Authentication credentials for bearer authentication. | +| `alerting..receiver_authentication_bearer.token_env` | string | - | only when `receiver_authentication_type` is `bearer` | Token Environmental variable for bearer authentication (Exclusive with `token_file`). | +| `alerting..receiver_authentication_bearer.token_file` | string | - | only when `receiver_authentication_type` is `bearer` | Token file for bearer authentication (Exclusive with `token_env`). | +| `alerting..receiver_authentication_bearer.authentication_scheme` | string (without spaces) | `Bearer` | | Prefix for Authorization header for bearer authentication. | + + +#### Examples + +It is possible to provide credentials for authentication of webhook requests beyond a hard to guess URL. For example, if you would like to receive notifications in your custom webhook authenticated with a bearer token taken from an environmental variable whenever Connaisseur admits a request to your cluster, your alerting configuration would look similar to the following snippet: -``` +```yaml alerting: admit_request: templates: @@ -78,9 +117,9 @@ alerting: You then have to set the `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_TOKEN` environment variable referencing the bearer token secret you want to use into the connaisseur deployment. -Or if you would like to use the service account token as the bearer token, you can use the following snippet: +Or if you need to load the token from a file you can use the following snipped: -``` +```yaml alerting: admit_request: templates: @@ -88,13 +127,12 @@ alerting: receiver_url: https://your.custom.domain.com/webhook/admit receiver_authentication_type: bearer receiver_authentication_bearer: - token_file: /var/run/secrets/kubernetes.io/serviceaccount/token + token_file: /etc/webhook/your-token ``` Finally in case of basic authentication, you can use the following snippet: - -``` +```yaml alerting: admit_request: templates: @@ -107,29 +145,23 @@ alerting: ``` You then have to set the `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_USERNAME` and `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_PASSWORD` environment variables referencing the secret you want to use into the connaisseur deployment. - - -## Additional notes - -### Creating a custom template - -Along the lines of the templates that already exist you can easily define -custom templates for other endpoints. The following variables can be rendered -during runtime into the payload: - -- `alert_message` -- `priority` -- `connaisseur_pod_id` -- `cluster` -- `timestamp` -- `request_id` -- `images` - -Referring to any of these variables in the templates works by Jinja2 notation -(e.g. `{{ timestamp }}`). You can update your payload dynamically by adding payload -fields in `yaml` representation in the `payload_fields` key which will be translated -to JSON by Helm as is. If your REST endpoint requires particular headers, you can -specify them as described above in `custom_headers`. - -Feel free to make a PR to share with the community if you add new neat templates for other third parties :pray: +This is an example of values configuration for basic authentication: +The referenced secret `connaisseur-webhook-user` should already exist. + +```yaml +deployment: + # Add environmental variables to the pod in the deployment + # referencing secrets, configMaps, or fields. + envValueFrom: + - name: CONNAISSSEUR_WEBHOOK_USERNAME + valueFrom: + secretKeyRef: + name: connaisseur-webhook-user + key: username + - name: CONNAISSSEUR_WEBHOOK_PASSWORD + valueFrom: + secretKeyRef: + name: connaisseur-webhook-user + key: password +``` From f4d02bb22eb8ce53163c65fa5ba5696d9343c263 Mon Sep 17 00:00:00 2001 From: Andrea Date: Sat, 5 Mar 2022 19:15:32 +0100 Subject: [PATCH 22/22] Improved the helm chart to reference existing secrets and config maps into the connaisseur pod. --- helm/templates/deployment.yaml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 855d3914b..2a7029aea 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -80,6 +80,30 @@ spec: valueFrom: fieldRef: fieldPath: metadata.name + {{- if .Values.deployment.env }} + {{- range $e := .Values.deployment.env }} + - name: {{ $e.name }} + value: {{ $e.value }} + {{- end }} + {{- end }} + {{- if .Values.deployment.envValueFrom}} + {{- range $e := .Values.deployment.envValueFrom }} + - name: {{ $e.name }} + valueFrom: + {{- if $e.valueFrom.secretKeyRef }} + secretKeyRef: + name: {{ $e.valueFrom.secretKeyRef.name }} + key: {{ $e.valueFrom.secretKeyRef.key }} + {{- else if $e.valueFrom.configMapKeyRef }} + configMapKeyRef: + name: {{ $e.valueFrom.configMapKeyRef.name }} + key: {{ $e.valueFrom.configMapKeyRef.key }} + {{- else if $e.valueFrom.fieldRef }} + fieldRef: + fieldPath: {{ $e.valueFrom.fieldRef.fieldPath }} + {{- end }} + {{- end }} + {{- end }} resources: {{- toYaml .Values.deployment.resources | nindent 12 }} securityContext: