From 572e790832ef1b1924c768c6caab12bde322a9ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Zaffarano?= Date: Wed, 5 Feb 2025 15:49:00 +0100 Subject: [PATCH 1/6] Add optional ssl config flag --- elementary/clients/slack/client.py | 49 +++++++++++++++++++++++------- elementary/config/config.py | 3 ++ elementary/monitor/cli.py | 9 ++++++ 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/elementary/clients/slack/client.py b/elementary/clients/slack/client.py index f66a9b969..2cf82ac5b 100644 --- a/elementary/clients/slack/client.py +++ b/elementary/clients/slack/client.py @@ -1,8 +1,10 @@ import json +import ssl from abc import ABC, abstractmethod from typing import Dict, List, Optional, Tuple, Union import requests +import certifi from ratelimit import limits, sleep_and_retry from slack_sdk import WebClient, WebhookClient from slack_sdk.errors import SlackApiError @@ -25,8 +27,9 @@ class SlackClient(ABC): def __init__( self, tracking: Optional[Tracking] = None, + ssl_context: Optional[ssl.SSLContext] = None, ): - self.client = self._initial_client() + self.client = self._initial_client(ssl_context) self.tracking = tracking self._initial_retry_handlers() self.email_to_user_id_cache: Dict[str, str] = {} @@ -38,19 +41,38 @@ def create_client( if not config.has_slack: return None if config.slack_token: - logger.debug("Creating Slack client with token.") - return SlackWebClient(token=config.slack_token, tracking=tracking) + logger.debug( + "Creating Slack client with token (system CA? = %s).", + config.use_system_ca_files, + ) + ssl_context = ( + None + if config.use_system_ca_files + else ssl.create_default_context(cafile=certifi.where()) + ) + return SlackWebClient( + token=config.slack_token, tracking=tracking, ssl_context=ssl_context + ) elif config.slack_webhook: - logger.debug("Creating Slack client with webhook.") + logger.debug( + "Creating Slack client with webhook (system CA? = %s).", + config.use_system_ca_files, + ) + ssl_context = ( + ssl.create_default_context(cafile=certifi.where()) + if not config.use_system_ca_files + else None + ) return SlackWebhookClient( webhook=config.slack_webhook, is_workflow=config.is_slack_workflow, tracking=tracking, + ssl_context=ssl_context ) return None @abstractmethod - def _initial_client(self): + def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): raise NotImplementedError def _initial_retry_handlers(self): @@ -85,12 +107,13 @@ def __init__( self, token: str, tracking: Optional[Tracking] = None, + ssl_context: Optional[ssl.SSLContext] = None, ): self.token = token - super().__init__(tracking) + super().__init__(tracking, ssl_context) - def _initial_client(self): - return WebClient(token=self.token) + def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): + return WebClient(token=self.token, ssl=ssl_context) @sleep_and_retry @limits(calls=1, period=ONE_SECOND) @@ -231,16 +254,20 @@ def __init__( webhook: str, is_workflow: bool, tracking: Optional[Tracking] = None, + ssl_context: Optional[ssl.SSLContext] = None, ): self.webhook = webhook self.is_workflow = is_workflow - super().__init__(tracking) + super().__init__(tracking, ssl_context) - def _initial_client(self): + def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): if self.is_workflow: return requests.Session() + return WebhookClient( - url=self.webhook, default_headers={"Content-type": "application/json"} + url=self.webhook, + default_headers={"Content-type": "application/json"}, + ssl=ssl_context, ) @sleep_and_retry diff --git a/elementary/config/config.py b/elementary/config/config.py index b8e56143c..18cbcbb09 100644 --- a/elementary/config/config.py +++ b/elementary/config/config.py @@ -76,6 +76,7 @@ def __init__( run_dbt_deps_if_needed: Optional[bool] = None, project_name: Optional[str] = None, quiet_logs: Optional[bool] = None, + use_system_ca_files: bool = True, ): self.config_dir = config_dir self.profiles_dir = profiles_dir @@ -222,6 +223,8 @@ def __init__( self.quiet_logs = self._first_not_none( quiet_logs, config.get("quiet_logs"), False ) + + self.use_system_ca_files = use_system_ca_files def _load_configuration(self) -> dict: if not os.path.exists(self.config_dir): diff --git a/elementary/monitor/cli.py b/elementary/monitor/cli.py index 9b47133ff..0550b434b 100644 --- a/elementary/monitor/cli.py +++ b/elementary/monitor/cli.py @@ -75,6 +75,11 @@ def decorator(func): default=None, help="The Slack token for your workspace.", )(func) + func = click.option( + "--use-system-ca-files/--no-use-system-ca-files", + default=True, + help="Whether to use the system CA files for SSL connections or the ones provided by certify (see https://pypi.org/project/certifi).", + )(func) if cmd in (Command.REPORT, Command.SEND_REPORT): func = click.option( "--exclude-elementary-models", @@ -331,6 +336,7 @@ def monitor( teams_webhook, maximum_columns_in_alert_samples, quiet_logs, + use_system_ca_files, ): """ Get alerts on failures in dbt jobs. @@ -365,6 +371,7 @@ def monitor( teams_webhook=teams_webhook, maximum_columns_in_alert_samples=maximum_columns_in_alert_samples, quiet_logs=quiet_logs, + use_system_ca_files=use_system_ca_files, ) anonymous_tracking = AnonymousCommandLineTracking(config) anonymous_tracking.set_env("use_select", bool(select)) @@ -692,6 +699,7 @@ def send_report( include, target_path, quiet_logs, + use_system_ca_files, ): """ Generate and send the report to an external platform. @@ -735,6 +743,7 @@ def send_report( env=env, project_name=project_name, quiet_logs=quiet_logs, + use_system_ca_files=use_system_ca_files, ) anonymous_tracking = AnonymousCommandLineTracking(config) anonymous_tracking.set_env("use_select", bool(select)) From ed661581235fec7088de36f78d82cd729afeefe3 Mon Sep 17 00:00:00 2001 From: Itamar Hartstein Date: Mon, 9 Feb 2026 22:12:01 +0200 Subject: [PATCH 2/6] fix: honor SSL context for Slack workflow webhooks - Update _initial_client in SlackWebhookClient to configure requests.Session with SSL verification - When ssl_context is provided, set session.verify to certifi CA bundle - Add warning when workflow webhooks cannot fully honor --use-system-ca-files setting - Ensures SSL context is respected for workflow webhook requests --- elementary/clients/slack/client.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/elementary/clients/slack/client.py b/elementary/clients/slack/client.py index 2cf82ac5b..0afedc214 100644 --- a/elementary/clients/slack/client.py +++ b/elementary/clients/slack/client.py @@ -3,8 +3,8 @@ from abc import ABC, abstractmethod from typing import Dict, List, Optional, Tuple, Union -import requests import certifi +import requests from ratelimit import limits, sleep_and_retry from slack_sdk import WebClient, WebhookClient from slack_sdk.errors import SlackApiError @@ -67,7 +67,7 @@ def create_client( webhook=config.slack_webhook, is_workflow=config.is_slack_workflow, tracking=tracking, - ssl_context=ssl_context + ssl_context=ssl_context, ) return None @@ -262,7 +262,18 @@ def __init__( def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): if self.is_workflow: - return requests.Session() + session = requests.Session() + if ssl_context is not None: + # For workflow webhooks, requests.Session doesn't directly support ssl.SSLContext, + # so we configure it to use certifi's CA bundle instead. + # The ssl_context parameter indicates that certifi should be used (not system CA files). + logger.warning( + "Workflow webhooks use requests.Session which doesn't fully support SSLContext. " + "Using certifi CA bundle for SSL verification instead of --use-system-ca-files setting." + ) + session.verify = certifi.where() + # If ssl_context is None, use system CA files (requests default behavior) + return session return WebhookClient( url=self.webhook, From 2c8f0e4111a63fd3950b9fb99ab92510876d1ed5 Mon Sep 17 00:00:00 2001 From: Itamar Hartstein Date: Tue, 10 Feb 2026 00:09:54 +0200 Subject: [PATCH 3/6] precommit fix --- elementary/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elementary/config/config.py b/elementary/config/config.py index 18cbcbb09..b15ff9c43 100644 --- a/elementary/config/config.py +++ b/elementary/config/config.py @@ -223,7 +223,7 @@ def __init__( self.quiet_logs = self._first_not_none( quiet_logs, config.get("quiet_logs"), False ) - + self.use_system_ca_files = use_system_ca_files def _load_configuration(self) -> dict: From 76877dc1b2073a66e222ba7577568843a341f970 Mon Sep 17 00:00:00 2001 From: Itamar Hartstein Date: Tue, 10 Feb 2026 00:14:46 +0200 Subject: [PATCH 4/6] fix is_workflow logic --- elementary/clients/slack/client.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/elementary/clients/slack/client.py b/elementary/clients/slack/client.py index 0afedc214..0c2814157 100644 --- a/elementary/clients/slack/client.py +++ b/elementary/clients/slack/client.py @@ -262,18 +262,9 @@ def __init__( def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): if self.is_workflow: - session = requests.Session() - if ssl_context is not None: - # For workflow webhooks, requests.Session doesn't directly support ssl.SSLContext, - # so we configure it to use certifi's CA bundle instead. - # The ssl_context parameter indicates that certifi should be used (not system CA files). - logger.warning( - "Workflow webhooks use requests.Session which doesn't fully support SSLContext. " - "Using certifi CA bundle for SSL verification instead of --use-system-ca-files setting." - ) - session.verify = certifi.where() - # If ssl_context is None, use system CA files (requests default behavior) - return session + # Workflow webhooks do not support the ssl_context parameter. + # requests.Session() uses the requests default CA bundle (certifi). + return requests.Session() return WebhookClient( url=self.webhook, From f475efea85fc1e56449b7f2011e2c282cc097a6f Mon Sep 17 00:00:00 2001 From: Itamar Hartstein Date: Sun, 1 Mar 2026 13:14:56 +0200 Subject: [PATCH 5/6] refactor: replace --use-system-ca-files with --ssl-ca-bundle Replace the boolean --use-system-ca-files/--no-use-system-ca-files flag with a more flexible --ssl-ca-bundle option that accepts 'certifi', 'system', or a custom file path. When omitted, each library keeps its own default CA behaviour (no change from prior behaviour). Key changes: - Add elementary/utils/ssl.py helper to resolve ssl_ca_bundle into SSLContext - Config now uses _first_not_none pattern so ssl_ca_bundle is also loadable from config.yml - Apply SSL context to both legacy SlackClient and newer SlackWebMessagingIntegration / SlackWebhookMessagingIntegration code paths Made-with: Cursor --- elementary/clients/slack/client.py | 21 +---------- elementary/config/config.py | 7 +++- .../messaging_integrations/slack_web.py | 9 ++++- .../messaging_integrations/slack_webhook.py | 8 +++- elementary/monitor/cli.py | 18 +++++---- .../alerts/integrations/integrations.py | 6 ++- elementary/utils/ssl.py | 37 +++++++++++++++++++ 7 files changed, 72 insertions(+), 34 deletions(-) create mode 100644 elementary/utils/ssl.py diff --git a/elementary/clients/slack/client.py b/elementary/clients/slack/client.py index 0c2814157..0c74a28a3 100644 --- a/elementary/clients/slack/client.py +++ b/elementary/clients/slack/client.py @@ -3,7 +3,6 @@ from abc import ABC, abstractmethod from typing import Dict, List, Optional, Tuple, Union -import certifi import requests from ratelimit import limits, sleep_and_retry from slack_sdk import WebClient, WebhookClient @@ -15,6 +14,7 @@ from elementary.config.config import Config from elementary.tracking.tracking_interface import Tracking from elementary.utils.log import get_logger +from elementary.utils.ssl import create_ssl_context logger = get_logger(__name__) @@ -40,29 +40,12 @@ def create_client( ) -> Optional["SlackClient"]: if not config.has_slack: return None + ssl_context = create_ssl_context(config.ssl_ca_bundle) if config.slack_token: - logger.debug( - "Creating Slack client with token (system CA? = %s).", - config.use_system_ca_files, - ) - ssl_context = ( - None - if config.use_system_ca_files - else ssl.create_default_context(cafile=certifi.where()) - ) return SlackWebClient( token=config.slack_token, tracking=tracking, ssl_context=ssl_context ) elif config.slack_webhook: - logger.debug( - "Creating Slack client with webhook (system CA? = %s).", - config.use_system_ca_files, - ) - ssl_context = ( - ssl.create_default_context(cafile=certifi.where()) - if not config.use_system_ca_files - else None - ) return SlackWebhookClient( webhook=config.slack_webhook, is_workflow=config.is_slack_workflow, diff --git a/elementary/config/config.py b/elementary/config/config.py index b15ff9c43..173db6c9d 100644 --- a/elementary/config/config.py +++ b/elementary/config/config.py @@ -76,7 +76,7 @@ def __init__( run_dbt_deps_if_needed: Optional[bool] = None, project_name: Optional[str] = None, quiet_logs: Optional[bool] = None, - use_system_ca_files: bool = True, + ssl_ca_bundle: Optional[str] = None, ): self.config_dir = config_dir self.profiles_dir = profiles_dir @@ -224,7 +224,10 @@ def __init__( quiet_logs, config.get("quiet_logs"), False ) - self.use_system_ca_files = use_system_ca_files + self.ssl_ca_bundle = self._first_not_none( + ssl_ca_bundle, + config.get("ssl_ca_bundle"), + ) def _load_configuration(self) -> dict: if not os.path.exists(self.config_dir): diff --git a/elementary/messages/messaging_integrations/slack_web.py b/elementary/messages/messaging_integrations/slack_web.py index 9135e2704..43de96a10 100644 --- a/elementary/messages/messaging_integrations/slack_web.py +++ b/elementary/messages/messaging_integrations/slack_web.py @@ -1,4 +1,5 @@ import json +import ssl import time from typing import Any, Dict, Iterator, Optional @@ -54,9 +55,13 @@ def __init__( @classmethod def from_token( - cls, token: str, tracking: Optional[Tracking] = None, **kwargs: Any + cls, + token: str, + tracking: Optional[Tracking] = None, + ssl_context: Optional[ssl.SSLContext] = None, + **kwargs: Any, ) -> "SlackWebMessagingIntegration": - client = WebClient(token=token) + client = WebClient(token=token, ssl=ssl_context) client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5)) return cls(client, tracking, **kwargs) diff --git a/elementary/messages/messaging_integrations/slack_webhook.py b/elementary/messages/messaging_integrations/slack_webhook.py index 32624f0b2..0ad28b42d 100644 --- a/elementary/messages/messaging_integrations/slack_webhook.py +++ b/elementary/messages/messaging_integrations/slack_webhook.py @@ -1,3 +1,4 @@ +import ssl from datetime import datetime from http import HTTPStatus from typing import Any, Optional @@ -37,9 +38,12 @@ def __init__( @classmethod def from_url( - cls, url: str, tracking: Optional[Tracking] = None + cls, + url: str, + tracking: Optional[Tracking] = None, + ssl_context: Optional[ssl.SSLContext] = None, ) -> "SlackWebhookMessagingIntegration": - client = WebhookClient(url) + client = WebhookClient(url, ssl=ssl_context) client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5)) return cls(client, tracking) diff --git a/elementary/monitor/cli.py b/elementary/monitor/cli.py index 0550b434b..c86b4b992 100644 --- a/elementary/monitor/cli.py +++ b/elementary/monitor/cli.py @@ -76,9 +76,13 @@ def decorator(func): help="The Slack token for your workspace.", )(func) func = click.option( - "--use-system-ca-files/--no-use-system-ca-files", - default=True, - help="Whether to use the system CA files for SSL connections or the ones provided by certify (see https://pypi.org/project/certifi).", + "--ssl-ca-bundle", + type=str, + default=None, + help="Override the CA bundle used for SSL connections. " + "Accepted values: 'certifi' (use the certifi package bundle), " + "'system' (use the OS CA store), or a file path to a custom CA bundle. " + "When omitted each underlying library uses its own default.", )(func) if cmd in (Command.REPORT, Command.SEND_REPORT): func = click.option( @@ -336,7 +340,7 @@ def monitor( teams_webhook, maximum_columns_in_alert_samples, quiet_logs, - use_system_ca_files, + ssl_ca_bundle, ): """ Get alerts on failures in dbt jobs. @@ -371,7 +375,7 @@ def monitor( teams_webhook=teams_webhook, maximum_columns_in_alert_samples=maximum_columns_in_alert_samples, quiet_logs=quiet_logs, - use_system_ca_files=use_system_ca_files, + ssl_ca_bundle=ssl_ca_bundle, ) anonymous_tracking = AnonymousCommandLineTracking(config) anonymous_tracking.set_env("use_select", bool(select)) @@ -699,7 +703,7 @@ def send_report( include, target_path, quiet_logs, - use_system_ca_files, + ssl_ca_bundle, ): """ Generate and send the report to an external platform. @@ -743,7 +747,7 @@ def send_report( env=env, project_name=project_name, quiet_logs=quiet_logs, - use_system_ca_files=use_system_ca_files, + ssl_ca_bundle=ssl_ca_bundle, ) anonymous_tracking = AnonymousCommandLineTracking(config) anonymous_tracking.set_env("use_select", bool(select)) diff --git a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py index 7a8ed59db..aeba80dae 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py @@ -23,6 +23,7 @@ ) from elementary.tracking.tracking_interface import Tracking from elementary.utils.log import get_logger +from elementary.utils.ssl import create_ssl_context logger = get_logger(__name__) @@ -43,6 +44,7 @@ def get_integration( tracking: Optional[Tracking] = None, ) -> Union[BaseMessagingIntegration, BaseIntegration]: if config.has_slack: + ssl_context = create_ssl_context(config.ssl_ca_bundle) if config.is_slack_workflow: return SlackIntegration( config=config, @@ -50,11 +52,11 @@ def get_integration( ) if config.slack_token: return SlackWebMessagingIntegration.from_token( - config.slack_token, tracking + config.slack_token, tracking, ssl_context=ssl_context ) elif config.slack_webhook: return SlackWebhookMessagingIntegration.from_url( - config.slack_webhook, tracking + config.slack_webhook, tracking, ssl_context=ssl_context ) else: raise UnsupportedAlertIntegrationError diff --git a/elementary/utils/ssl.py b/elementary/utils/ssl.py new file mode 100644 index 000000000..d22478f2a --- /dev/null +++ b/elementary/utils/ssl.py @@ -0,0 +1,37 @@ +import os +import ssl +from typing import Optional + +import certifi + +from elementary.utils.log import get_logger + +logger = get_logger(__name__) + +CERTIFI = "certifi" +SYSTEM = "system" + + +def create_ssl_context(ssl_ca_bundle: Optional[str] = None) -> Optional[ssl.SSLContext]: + """Resolve an ssl_ca_bundle setting into an SSLContext. + + Returns ``None`` when *ssl_ca_bundle* is ``None`` so that each + library keeps its own default CA behaviour. + """ + if ssl_ca_bundle is None: + return None + + value = ssl_ca_bundle.strip() + + if value.lower() == CERTIFI: + logger.debug("Using certifi CA bundle for SSL context.") + return ssl.create_default_context(cafile=certifi.where()) + + if value.lower() == SYSTEM: + logger.debug("Using system CA store for SSL context.") + return ssl.create_default_context() + + if not os.path.isfile(value): + raise ValueError(f"ssl_ca_bundle path does not exist or is not a file: {value}") + logger.debug("Using custom CA bundle for SSL context: %s", value) + return ssl.create_default_context(cafile=value) From 2395c2f0983d4ec0a4837664ba71fb7f9d278f9f Mon Sep 17 00:00:00 2001 From: Itamar Hartstein Date: Sun, 1 Mar 2026 13:25:36 +0200 Subject: [PATCH 6/6] fix: validate empty ssl_ca_bundle values explicitly Raise a clear error when ssl_ca_bundle is an empty or whitespace-only string, instead of falling through to the file path check which would produce a confusing "path does not exist" error. Made-with: Cursor --- elementary/utils/ssl.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/elementary/utils/ssl.py b/elementary/utils/ssl.py index d22478f2a..ffd5ea257 100644 --- a/elementary/utils/ssl.py +++ b/elementary/utils/ssl.py @@ -22,6 +22,10 @@ def create_ssl_context(ssl_ca_bundle: Optional[str] = None) -> Optional[ssl.SSLC return None value = ssl_ca_bundle.strip() + if not value: + raise ValueError( + "ssl_ca_bundle cannot be empty. Use 'certifi', 'system', or a CA bundle file path." + ) if value.lower() == CERTIFI: logger.debug("Using certifi CA bundle for SSL context.")