Skip to content

Commit 96a4170

Browse files
Add optional --ssl-ca-bundle flag for SSL certificate configuration (#1802)
* Add optional ssl config flag * 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 * precommit fix * fix is_workflow logic * 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 * 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 --------- Co-authored-by: Itamar Hartstein <haritamar@gmail.com>
1 parent 404708b commit 96a4170

File tree

7 files changed

+100
-17
lines changed

7 files changed

+100
-17
lines changed

elementary/clients/slack/client.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import ssl
23
from abc import ABC, abstractmethod
34
from typing import Dict, List, Optional, Tuple, Union
45

@@ -13,6 +14,7 @@
1314
from elementary.config.config import Config
1415
from elementary.tracking.tracking_interface import Tracking
1516
from elementary.utils.log import get_logger
17+
from elementary.utils.ssl import create_ssl_context
1618

1719
logger = get_logger(__name__)
1820

@@ -25,8 +27,9 @@ class SlackClient(ABC):
2527
def __init__(
2628
self,
2729
tracking: Optional[Tracking] = None,
30+
ssl_context: Optional[ssl.SSLContext] = None,
2831
):
29-
self.client = self._initial_client()
32+
self.client = self._initial_client(ssl_context)
3033
self.tracking = tracking
3134
self._initial_retry_handlers()
3235
self.email_to_user_id_cache: Dict[str, str] = {}
@@ -37,20 +40,22 @@ def create_client(
3740
) -> Optional["SlackClient"]:
3841
if not config.has_slack:
3942
return None
43+
ssl_context = create_ssl_context(config.ssl_ca_bundle)
4044
if config.slack_token:
41-
logger.debug("Creating Slack client with token.")
42-
return SlackWebClient(token=config.slack_token, tracking=tracking)
45+
return SlackWebClient(
46+
token=config.slack_token, tracking=tracking, ssl_context=ssl_context
47+
)
4348
elif config.slack_webhook:
44-
logger.debug("Creating Slack client with webhook.")
4549
return SlackWebhookClient(
4650
webhook=config.slack_webhook,
4751
is_workflow=config.is_slack_workflow,
4852
tracking=tracking,
53+
ssl_context=ssl_context,
4954
)
5055
return None
5156

5257
@abstractmethod
53-
def _initial_client(self):
58+
def _initial_client(self, ssl_context: Optional[ssl.SSLContext]):
5459
raise NotImplementedError
5560

5661
def _initial_retry_handlers(self):
@@ -85,12 +90,13 @@ def __init__(
8590
self,
8691
token: str,
8792
tracking: Optional[Tracking] = None,
93+
ssl_context: Optional[ssl.SSLContext] = None,
8894
):
8995
self.token = token
90-
super().__init__(tracking)
96+
super().__init__(tracking, ssl_context)
9197

92-
def _initial_client(self):
93-
return WebClient(token=self.token)
98+
def _initial_client(self, ssl_context: Optional[ssl.SSLContext]):
99+
return WebClient(token=self.token, ssl=ssl_context)
94100

95101
@sleep_and_retry
96102
@limits(calls=1, period=ONE_SECOND)
@@ -231,16 +237,22 @@ def __init__(
231237
webhook: str,
232238
is_workflow: bool,
233239
tracking: Optional[Tracking] = None,
240+
ssl_context: Optional[ssl.SSLContext] = None,
234241
):
235242
self.webhook = webhook
236243
self.is_workflow = is_workflow
237-
super().__init__(tracking)
244+
super().__init__(tracking, ssl_context)
238245

239-
def _initial_client(self):
246+
def _initial_client(self, ssl_context: Optional[ssl.SSLContext]):
240247
if self.is_workflow:
248+
# Workflow webhooks do not support the ssl_context parameter.
249+
# requests.Session() uses the requests default CA bundle (certifi).
241250
return requests.Session()
251+
242252
return WebhookClient(
243-
url=self.webhook, default_headers={"Content-type": "application/json"}
253+
url=self.webhook,
254+
default_headers={"Content-type": "application/json"},
255+
ssl=ssl_context,
244256
)
245257

246258
@sleep_and_retry

elementary/config/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def __init__(
7676
run_dbt_deps_if_needed: Optional[bool] = None,
7777
project_name: Optional[str] = None,
7878
quiet_logs: Optional[bool] = None,
79+
ssl_ca_bundle: Optional[str] = None,
7980
):
8081
self.config_dir = config_dir
8182
self.profiles_dir = profiles_dir
@@ -223,6 +224,11 @@ def __init__(
223224
quiet_logs, config.get("quiet_logs"), False
224225
)
225226

227+
self.ssl_ca_bundle = self._first_not_none(
228+
ssl_ca_bundle,
229+
config.get("ssl_ca_bundle"),
230+
)
231+
226232
def _load_configuration(self) -> dict:
227233
config_file_path = os.path.join(self.config_dir, self._CONFIG_FILE_NAME)
228234
if not os.path.exists(config_file_path):

elementary/messages/messaging_integrations/slack_web.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import ssl
23
import time
34
from typing import Any, Dict, Iterator, Optional
45

@@ -54,9 +55,13 @@ def __init__(
5455

5556
@classmethod
5657
def from_token(
57-
cls, token: str, tracking: Optional[Tracking] = None, **kwargs: Any
58+
cls,
59+
token: str,
60+
tracking: Optional[Tracking] = None,
61+
ssl_context: Optional[ssl.SSLContext] = None,
62+
**kwargs: Any,
5863
) -> "SlackWebMessagingIntegration":
59-
client = WebClient(token=token)
64+
client = WebClient(token=token, ssl=ssl_context)
6065
client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5))
6166
return cls(client, tracking, **kwargs)
6267

elementary/messages/messaging_integrations/slack_webhook.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import ssl
12
from datetime import datetime, timezone
23
from http import HTTPStatus
34
from typing import Any, Optional
@@ -37,9 +38,12 @@ def __init__(
3738

3839
@classmethod
3940
def from_url(
40-
cls, url: str, tracking: Optional[Tracking] = None
41+
cls,
42+
url: str,
43+
tracking: Optional[Tracking] = None,
44+
ssl_context: Optional[ssl.SSLContext] = None,
4145
) -> "SlackWebhookMessagingIntegration":
42-
client = WebhookClient(url)
46+
client = WebhookClient(url, ssl=ssl_context)
4347
client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5))
4448
return cls(client, tracking)
4549

elementary/monitor/cli.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ def decorator(func):
7575
default=None,
7676
help="The Slack token for your workspace.",
7777
)(func)
78+
func = click.option(
79+
"--ssl-ca-bundle",
80+
type=str,
81+
default=None,
82+
help="Override the CA bundle used for SSL connections. "
83+
"Accepted values: 'certifi' (use the certifi package bundle), "
84+
"'system' (use the OS CA store), or a file path to a custom CA bundle. "
85+
"When omitted each underlying library uses its own default.",
86+
)(func)
7887
if cmd in (Command.REPORT, Command.SEND_REPORT):
7988
func = click.option(
8089
"--exclude-elementary-models",
@@ -331,6 +340,7 @@ def monitor(
331340
teams_webhook,
332341
maximum_columns_in_alert_samples,
333342
quiet_logs,
343+
ssl_ca_bundle,
334344
):
335345
"""
336346
Get alerts on failures in dbt jobs.
@@ -365,6 +375,7 @@ def monitor(
365375
teams_webhook=teams_webhook,
366376
maximum_columns_in_alert_samples=maximum_columns_in_alert_samples,
367377
quiet_logs=quiet_logs,
378+
ssl_ca_bundle=ssl_ca_bundle,
368379
)
369380
anonymous_tracking = AnonymousCommandLineTracking(config)
370381
anonymous_tracking.set_env("use_select", bool(select))
@@ -692,6 +703,7 @@ def send_report(
692703
include,
693704
target_path,
694705
quiet_logs,
706+
ssl_ca_bundle,
695707
):
696708
"""
697709
Generate and send the report to an external platform.
@@ -735,6 +747,7 @@ def send_report(
735747
env=env,
736748
project_name=project_name,
737749
quiet_logs=quiet_logs,
750+
ssl_ca_bundle=ssl_ca_bundle,
738751
)
739752
anonymous_tracking = AnonymousCommandLineTracking(config)
740753
anonymous_tracking.set_env("use_select", bool(select))

elementary/monitor/data_monitoring/alerts/integrations/integrations.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
)
2424
from elementary.tracking.tracking_interface import Tracking
2525
from elementary.utils.log import get_logger
26+
from elementary.utils.ssl import create_ssl_context
2627

2728
logger = get_logger(__name__)
2829

@@ -43,18 +44,19 @@ def get_integration(
4344
tracking: Optional[Tracking] = None,
4445
) -> Union[BaseMessagingIntegration, BaseIntegration]:
4546
if config.has_slack:
47+
ssl_context = create_ssl_context(config.ssl_ca_bundle)
4648
if config.is_slack_workflow:
4749
return SlackIntegration(
4850
config=config,
4951
tracking=tracking,
5052
)
5153
if config.slack_token:
5254
return SlackWebMessagingIntegration.from_token(
53-
config.slack_token, tracking
55+
config.slack_token, tracking, ssl_context=ssl_context
5456
)
5557
elif config.slack_webhook:
5658
return SlackWebhookMessagingIntegration.from_url(
57-
config.slack_webhook, tracking
59+
config.slack_webhook, tracking, ssl_context=ssl_context
5860
)
5961
else:
6062
raise UnsupportedAlertIntegrationError

elementary/utils/ssl.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import os
2+
import ssl
3+
from typing import Optional
4+
5+
import certifi
6+
7+
from elementary.utils.log import get_logger
8+
9+
logger = get_logger(__name__)
10+
11+
CERTIFI = "certifi"
12+
SYSTEM = "system"
13+
14+
15+
def create_ssl_context(ssl_ca_bundle: Optional[str] = None) -> Optional[ssl.SSLContext]:
16+
"""Resolve an ssl_ca_bundle setting into an SSLContext.
17+
18+
Returns ``None`` when *ssl_ca_bundle* is ``None`` so that each
19+
library keeps its own default CA behaviour.
20+
"""
21+
if ssl_ca_bundle is None:
22+
return None
23+
24+
value = ssl_ca_bundle.strip()
25+
if not value:
26+
raise ValueError(
27+
"ssl_ca_bundle cannot be empty. Use 'certifi', 'system', or a CA bundle file path."
28+
)
29+
30+
if value.lower() == CERTIFI:
31+
logger.debug("Using certifi CA bundle for SSL context.")
32+
return ssl.create_default_context(cafile=certifi.where())
33+
34+
if value.lower() == SYSTEM:
35+
logger.debug("Using system CA store for SSL context.")
36+
return ssl.create_default_context()
37+
38+
if not os.path.isfile(value):
39+
raise ValueError(f"ssl_ca_bundle path does not exist or is not a file: {value}")
40+
logger.debug("Using custom CA bundle for SSL context: %s", value)
41+
return ssl.create_default_context(cafile=value)

0 commit comments

Comments
 (0)