From 5351b181d3ebd87dde31c9b44f299870ad45c8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Deuerling?= Date: Thu, 2 Jul 2026 07:58:04 +0200 Subject: [PATCH] Add --system-truststore option for OS trust store TLS verification --- CHANGELOG.md | 2 ++ README.md | 18 +++++++++++++++++ pyproject.toml | 1 + src/entropy_data/cli.py | 24 +++++++++++++++++++++++ tests/test_cli.py | 43 +++++++++++++++++++++++++++++++++++++++++ uv.lock | 13 ++++++++++++- 6 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/test_cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 970fab1..ab77587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Add the global `--system-truststore` option (env `ENTROPY_DATA_SYSTEM_TRUSTSTORE`) to verify TLS using the operating system's certificate trust store instead of the bundled CA certificates, for use behind a corporate proxy or with an internal CA. + ## [0.3.15] - Replace `entropy-data organization custom-team-roles ...` and the interim `organization team-roles-mode` command with `entropy-data settings team-roles get|put`. The configuration is now a single aggregate payload — `{mode: default}` or `{mode: custom-team-roles, team-roles: [...]}` — wrapping the new `GET`/`PUT /api/settings/team-roles`. `put` replaces the whole catalog when mode is `custom-team-roles`; an empty catalog or dropping a role still assigned to a member is rejected with 409. diff --git a/README.md b/README.md index 66e7e6f..0ded12a 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,24 @@ Values from `.env` are loaded as environment variables and do **not** override a Resolution precedence: CLI options > environment variables / `.env` > config file. +### TLS behind a corporate proxy or internal CA + +By default the CLI verifies TLS certificates against the bundled CA certificates (`certifi`). In a corporate network with a TLS-inspecting proxy or an internal certificate authority, this can fail with `CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate`, because the root CA is installed in the operating system's trust store but not in the bundled list. + +Use the global `--system-truststore` option to verify against the operating system's trust store (macOS Keychain, Windows certificate store, or the system CA certificates on Linux) instead: + +```bash +entropy-data --system-truststore datacontracts list +``` + +You can also enable it for every invocation by exporting an environment variable: + +```bash +export ENTROPY_DATA_SYSTEM_TRUSTSTORE=1 +``` + +This keeps certificate verification on while trusting the corporate root CA, and applies to every command that makes HTTPS requests. + ## Development ```bash diff --git a/pyproject.toml b/pyproject.toml index af57367..f05906d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "tomli_w>=1.0,<2.0", "pyyaml>=6.0,<7.0", "python-dotenv>=1.0,<2.0", + "truststore>=0.10,<1.0", ] [project.urls] diff --git a/src/entropy_data/cli.py b/src/entropy_data/cli.py index afdcb87..2611a2c 100644 --- a/src/entropy_data/cli.py +++ b/src/entropy_data/cli.py @@ -57,6 +57,19 @@ def version_callback(value: bool) -> None: raise typer.Exit() +def inject_system_truststore() -> None: + """Verify TLS using the operating system's certificate trust store instead of the + bundled CA certificates. This lets the CLI work behind corporate proxies or with + internal CAs whose root certificates are installed in the OS trust store but not in + the certifi bundle that requests uses by default.""" + try: + import truststore + except ImportError: + error_console.print("[red]--system-truststore requires the 'truststore' package, which is not installed.[/red]") + raise typer.Exit(code=1) + truststore.inject_into_ssl() + + app = typer.Typer( name="entropy-data", help="CLI for Entropy Data.", @@ -76,6 +89,15 @@ def main( host: Annotated[Optional[str], typer.Option("--host", help="API host URL (overrides config and env).")] = None, output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format.")] = OutputFormat.table, debug: Annotated[bool, typer.Option("--debug", help="Enable debug output.")] = False, + system_truststore: Annotated[ + bool, + typer.Option( + "--system-truststore", + help="Verify TLS using the operating system's certificate trust store " + "instead of the bundled CA certificates (e.g. behind a corporate proxy or internal CA).", + envvar="ENTROPY_DATA_SYSTEM_TRUSTSTORE", + ), + ] = False, ) -> None: """Entropy Data CLI — manage your data platform from the command line.""" global _connection_name, _cli_api_key, _cli_host, _output_format, _debug @@ -87,6 +109,8 @@ def main( _debug = debug if debug: logging.basicConfig(level=logging.DEBUG, stream=sys.stderr) + if system_truststore: + inject_system_truststore() # Register command groups diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4f740a4 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,43 @@ +"""Tests for the top-level CLI app and its global options.""" + +import re + +from typer.testing import CliRunner + +from entropy_data.cli import app + +runner = CliRunner() + + +def test_system_truststore_option_in_help(): + result = runner.invoke(app, ["--help"], env={"COLUMNS": "200"}) + assert result.exit_code == 0 + plain_output = re.sub(r"\x1b\[[0-9;]*m", "", result.stdout) + assert "--system-truststore" in plain_output + + +def test_system_truststore_flag_injects(monkeypatch): + calls = [] + monkeypatch.setattr("entropy_data.cli.inject_system_truststore", lambda: calls.append(True)) + result = runner.invoke(app, ["--system-truststore", "teams", "list"]) + # The command fails without a configured connection, but the app callback has run. + assert result.exit_code != 0 + assert calls == [True] + + +def test_system_truststore_env_var_injects(monkeypatch): + calls = [] + monkeypatch.setattr("entropy_data.cli.inject_system_truststore", lambda: calls.append(True)) + monkeypatch.setenv("ENTROPY_DATA_SYSTEM_TRUSTSTORE", "1") + result = runner.invoke(app, ["teams", "list"]) + assert result.exit_code != 0 + assert calls == [True] + + +def test_system_truststore_not_injected_by_default(monkeypatch): + calls = [] + monkeypatch.setattr("entropy_data.cli.inject_system_truststore", lambda: calls.append(True)) + monkeypatch.delenv("ENTROPY_DATA_SYSTEM_TRUSTSTORE", raising=False) + result = runner.invoke(app, ["teams", "list"]) + assert result.exit_code != 0 + assert calls == [] diff --git a/uv.lock b/uv.lock index abe417b..64c13b7 100644 --- a/uv.lock +++ b/uv.lock @@ -143,7 +143,7 @@ wheels = [ [[package]] name = "entropy-data" -version = "0.3.13" +version = "0.3.15" source = { editable = "." } dependencies = [ { name = "pydantic" }, @@ -152,6 +152,7 @@ dependencies = [ { name = "requests" }, { name = "rich" }, { name = "tomli-w" }, + { name = "truststore" }, { name = "typer" }, ] @@ -175,6 +176,7 @@ requires-dist = [ { name = "rich", specifier = ">=14.0,<16.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" }, { name = "tomli-w", specifier = ">=1.0,<2.0" }, + { name = "truststore", specifier = ">=0.10,<1.0" }, { name = "typer", specifier = ">=0.15.0,<1.0" }, ] provides-extras = ["dev"] @@ -587,6 +589,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.24.1"