Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .docker/kerberos-kdc/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM rockylinux:9

RUN yum install -y ca-certificates krb5-server krb5-libs krb5-workstation

EXPOSE 88 749

RUN touch /config.sh
# Overwritten via the docker-compose volume mount -- see tests/integration_tests/kerberos_conf/kerberos_image_config.sh

ENTRYPOINT ["/bin/bash", "/config.sh"]
49 changes: 48 additions & 1 deletion .github/workflows/on_push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,56 @@ jobs:
COMPOSE_PROJECT_NAME: clickhouse-connect-ci
run: docker compose -f docker-compose.yml down --volumes --remove-orphans

kerberos-integration-test:
runs-on: ubuntu-latest
needs: bare-import-test
name: Kerberos Integration Tests
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Start ClickHouse 25.8 in Docker
env:
CLICKHOUSE_CONNECT_TEST_CH_VERSION: '25.8'
COMPOSE_PROJECT_NAME: clickhouse-connect-ci
run: docker compose -f docker-compose.yml up -d --wait clickhouse
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install pip
run: python -m pip install --upgrade pip
- name: Install system Kerberos packages
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y gcc python3-dev libkrb5-dev krb5-user
- name: Add Kerberos ClickHouse instance to /etc/hosts
run: sudo echo "127.0.0.1 server1.clickhouse.test" | sudo tee -a /etc/hosts
- name: Install Test Dependencies
run: |
pip install -r tests/test_requirements.txt
pip install "pyspnego[kerberos]>=0.10"
- name: Build cython extensions
run: python setup.py build_ext --inplace
- name: "Add distribution info"
run: pip install -e . --no-deps
- name: Run tests
env:
CLICKHOUSE_CONNECT_TEST_KERBEROS: '1'
run: pytest --dist=loadgroup tests/integration_tests/test_kerberos.py
- name: Stop Kerberos services
if: ${{ always() && hashFiles('docker-compose.yml') != '' }}
env:
COMPOSE_PROJECT_NAME: clickhouse-connect-kerberos
run: docker compose -f docker-compose.yml --profile kerberos down --volumes --remove-orphans
- name: Stop ClickHouse
if: ${{ always() && hashFiles('docker-compose.yml') != '' }}
env:
COMPOSE_PROJECT_NAME: clickhouse-connect-ci
run: docker compose -f docker-compose.yml down --volumes --remove-orphans

check-secret:
runs-on: ubuntu-latest
needs: [bare-import-test, tests, pandas-3x-compat-test, sqlalchemy-1x-compat-test]
needs: [bare-import-test, tests, pandas-3x-compat-test, sqlalchemy-1x-compat-test, kerberos-integration-test]
outputs:
has_secrets: ${{ steps.has_secrets.outputs.HAS_SECRETS }}
steps:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
- SQLAlchemy ClickHouse type literals containing percent signs now compile safely alongside remaining bound parameters, and server-side parameter mode preserves consecutive percent signs in literals. Closes [#966](https://github.com/ClickHouse/clickhouse-connect/issues/966).
- SQLAlchemy now applies ClickHouse backslash escaping to generic `literal_binds` strings, string `DEFAULT`, `MATERIALIZED`, `ALIAS`, and `TTL` clauses, CREATE comments, and Alembic table and column comment operations. Backslash values now round-trip verbatim instead of being reinterpreted or terminating a quoted literal. ClickHouse-native literal processors and percent handling are unchanged. If custom `TypeDecorator.process_literal_param` or `UserDefinedType` code pre-escaped backslashes as a workaround, remove that workaround because the dialect now applies ClickHouse escaping. Closes [#975](https://github.com/ClickHouse/clickhouse-connect/issues/975).

### Improvements

- Added Kerberos authentication through the HTTP `Negotiate` scheme for both clients via the new `use_kerberos` and `kerberos_hostname_override` connection parameters, using the current process's Kerberos credential cache. Kerberos support is experimental. This requires the new `kerberos` extra: `pip install clickhouse-connect[kerberos]`. Because ClickHouse authenticates each HTTP request independently rather than caching authentication for a session, each authenticated request attempt uses a fresh preemptive Kerberos token and validates the successful authenticated response token to complete mutual authentication. Closes [#128](https://github.com/ClickHouse/clickhouse-connect/issues/128).

## 1.7.1, 2026-08-12

### Bug Fixes
Expand Down
48 changes: 46 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ python setup.py develop

### Add /etc/hosts entry

Required for TLS tests.
Required for TLS and Kerberos tests.
The generated certificates assume TLS requests use `server1.clickhouse.test` as the hostname.
See [test_tls.py](tests/integration_tests/test_tls.py) for more details.
See [test_tls.py](tests/integration_tests/test_tls.py) and [test_kerberos.py](tests/integration_tests/test_kerberos.py) for more details.

```bash
sudo -- sh -c "echo 127.0.0.1 server1.clickhouse.test >> /etc/hosts"
Expand Down Expand Up @@ -120,6 +120,50 @@ Additionally, the TLS ClickHouse instance should be running (see [docker-compose
CLICKHOUSE_CONNECT_TEST_TLS=1 pytest tests/integration_tests/test_tls.py
```

### Run the Kerberos integration tests

These tests require the `CLICKHOUSE_CONNECT_TEST_KERBEROS` environment variable to be set to `1`; otherwise, they will be skipped.
Unlike the other test instances, the Kerberos KDC and ClickHouse instance (the `kerberos_kdc` and `kerberos_clickhouse` services in
[docker-compose.yml](docker-compose.yml)) are behind a `kerberos` Compose profile rather than started by a plain `docker compose up -d`,
since they also need an extra host-side step (obtaining a real Kerberos ticket) that Docker Compose cannot do for you. This walks
through setting them up from scratch.

Install the system Kerberos client and development packages (needed to build the `gssapi`/`krb5` Python packages):

```bash
# Debian/Ubuntu
sudo apt-get install gcc python3-dev libkrb5-dev krb5-user

# CentOS/RHEL/Fedora
sudo dnf install gcc python3-devel krb5-devel krb5-workstation

# Arch Linux
sudo pacman -S gcc krb5
```

Make sure you've added the `server1.clickhouse.test` `/etc/hosts` entry from
["Add /etc/hosts entry"](#add-etchosts-entry) above.

The rest (starting a KDC and a Kerberos-configured ClickHouse instance, obtaining a ticket, and tearing it all
back down afterward) is handled automatically by a fixture in
[`test_kerberos.py`](tests/integration_tests/test_kerberos.py), via
[`kerberos_manage.py`](tests/integration_tests/kerberos_manage.py), which uses the fixtures vendored under
[`tests/integration_tests/kerberos_conf`](tests/integration_tests/kerberos_conf).

Run from the repo root:

```bash
CLICKHOUSE_CONNECT_TEST_KERBEROS=1 pytest --dist=loadgroup tests/integration_tests/test_kerberos.py
```

To stand up (or tear down) the same environment by hand, outside of pytest -- for example, to poke at it manually
with `curl --negotiate` -- run:

```bash
python -m tests.integration_tests.kerberos_manage setup
python -m tests.integration_tests.kerberos_manage teardown
```

### Running the integration tests with ClickHouse Cloud

If you want to run the tests using your ClickHouse Cloud instance instead of the local ClickHouse instance running in Docker, you will need a few additional environment variables.
Expand Down
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,56 @@ pip install clickhouse-connect[async]
Then create a client with `clickhouse_connect.get_async_client()`. See the
[run_async example](./examples/run_async.py) for more details.

### Kerberos Authentication

Kerberos authentication is experimental.

ClickHouse Connect supports Kerberos authentication through the HTTP `Negotiate` scheme against a ClickHouse server
configured for Kerberos. Each authenticated request attempt uses a fresh preemptive Kerberos token and validates the
successful authenticated response token to complete mutual authentication.

Install the system Kerberos client and development packages first (needed to build the `gssapi`/`krb5` Python
packages):

```bash
# Debian/Ubuntu
sudo apt-get install gcc python3-dev libkrb5-dev krb5-user

# CentOS/RHEL/Fedora
sudo dnf install gcc python3-devel krb5-devel krb5-workstation

# Arch Linux
sudo pacman -S gcc krb5
```

Then install the optional dependency:

```bash
pip install clickhouse-connect[kerberos]
```

Obtain a Kerberos ticket first, for example by running `kinit`. The client uses the current process's Kerberos
credential cache. It does not acquire a ticket from a username and password.

Create a synchronous client with `use_kerberos=True`:

```python
import clickhouse_connect

client = clickhouse_connect.get_client(host="localhost", use_kerberos=True)
```

The same options work with `await clickhouse_connect.get_async_client(...)`. Install both extras with
`pip install "clickhouse-connect[async,kerberos]"` for async use.

If the hostname you connect through doesn't match the server's Kerberos service principal name (for example,
connecting via an IP address or load balancer), pass `kerberos_hostname_override` with the principal's actual
hostname.

Do not combine Kerberos authentication with `username`, `password`, `access_token`, `token_provider`, or
`client_cert`. Each authenticated HTTP request attempt sends a fresh preemptive token. Every successful authenticated
response must contain a `WWW-Authenticate: Negotiate <token>` response header that completes mutual authentication.

### Complete Documentation

The documentation for ClickHouse Connect has moved to
Expand Down
14 changes: 14 additions & 0 deletions clickhouse_connect/driver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,13 @@ def create_client(
:param token_provider: A callable returning a JWT access token (ClickHouse Cloud feature). Called for the initial token and
again to refresh it whenever the server rejects the current one.
Should not be set if `access_token` or `username`/`password` are used.
:param use_kerberos: Use Kerberos authentication through the HTTP `Negotiate` scheme against a ClickHouse server
configured for Kerberos, using the current process's Kerberos credential cache (equivalent to having run `kinit` beforehand).
Requires the kerberos extra: pip install clickhouse-connect[kerberos]. Cannot be combined with `access_token`,
`token_provider`, `username`/`password`, or `client_cert`.
:param kerberos_hostname_override: Override the hostname used to build the Kerberos service principal name
(`HTTP/<hostname>`). Defaults to `host`. Useful when connecting via an IP address or a load balancer whose
name does not match the ClickHouse server's Kerberos principal.
:param database: The default database for the connection. If not set, ClickHouse Connect will use the
default database for username.
:param interface: Must be http, https, or chdb. Defaults to http, or to https if port is set to 8443 or 443.
Expand Down Expand Up @@ -364,6 +371,13 @@ async def create_async_client(
:param token_provider: A callable returning a JWT access token. Called for the initial token and
again to refresh it whenever the server rejects the current one. Because multiple in-flight requests
may each trigger a refresh concurrently, the callable must be safe to invoke in parallel.
:param use_kerberos: Use Kerberos authentication through the HTTP `Negotiate` scheme against a ClickHouse server
configured for Kerberos, using the current process's Kerberos credential cache (equivalent to having run `kinit` beforehand).
Requires the kerberos extra: pip install clickhouse-connect[kerberos]. Cannot be combined with `access_token`,
`token_provider`, `username`/`password`, or `client_cert`.
:param kerberos_hostname_override: Override the hostname used to build the Kerberos service principal name
(`HTTP/<hostname>`). Defaults to `host`. Useful when connecting via an IP address or a load balancer whose
name does not match the ClickHouse server's Kerberos principal.
:param database: The default database for the connection. If not set, ClickHouse Connect will use the
default database for username.
:param interface: Must be http or https. Defaults to http, or to https if port is set to 8443 or 443
Expand Down
26 changes: 25 additions & 1 deletion clickhouse_connect/driver/_backend/http_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from clickhouse_connect.driver._backend.models import Capabilities, CommandExecution, QueryExecution, QueryRuntime
from clickhouse_connect.driver.common import ShowClickHouseErrors, dict_copy
from clickhouse_connect.driver.exceptions import OperationalError, ProgrammingError
from clickhouse_connect.driver.kerberos import KerberosAuthContext
from clickhouse_connect.driver.streaming import start_streaming_response

if TYPE_CHECKING:
Expand Down Expand Up @@ -159,6 +160,8 @@ def __init__(
server_host_name: str | None,
token_provider: Callable[[], str | Awaitable[str]] | None,
autogenerate_query_id: bool,
use_kerberos: bool = False,
kerberos_hostname: str | None = None,
read_format: str = "Native",
form_encode_query_params: bool = False,
):
Expand All @@ -172,6 +175,8 @@ def __init__(
self.proxy_url = proxy_url
self.server_host_name = server_host_name
self.token_provider = token_provider
self.use_kerberos = use_kerberos
self.kerberos_hostname = kerberos_hostname
self.autogenerate_query_id = autogenerate_query_id
self.read_format = read_format
self.form_encode_query_params = form_encode_query_params
Expand Down Expand Up @@ -474,9 +479,22 @@ async def request(
lease.acquire()
lease_released = False
try:
kerberos_context = None
if self.use_kerberos:
assert self.kerberos_hostname is not None
kerberos_context = KerberosAuthContext(self.kerberos_hostname)
req_headers["Authorization"] = kerberos_context.authorization_header
Comment on lines +482 to +486
# Construct full URL (aiohttp doesn't have base_url)
url = self._base_url
request_kwargs = {"method": method, "url": url, "params": final_params, "headers": req_headers}
request_method = "POST" if self.use_kerberos and method == "GET" else method
request_kwargs: dict[str, Any] = {
"method": request_method,
"url": url,
"params": final_params,
"headers": req_headers,
}
if self.use_kerberos:
request_kwargs["allow_redirects"] = False
if self.server_host_name and self.ssl_context is not None:
request_kwargs["ssl"] = self.ssl_context
request_kwargs["server_hostname"] = self.server_host_name
Expand Down Expand Up @@ -507,6 +525,12 @@ async def request(

response = await session.request(**request_kwargs)
if 200 <= response.status < 300 and not response.headers.get(ex_header):
if kerberos_context is not None:
try:
kerberos_context.validate_response(response.headers.get("WWW-Authenticate"))
except Exception:
response.close()
raise
# Caller releases lease after consuming the body.
response._lease_release = _one_shot(lease.release) # type: ignore[attr-defined]
lease_released = True
Expand Down
24 changes: 23 additions & 1 deletion clickhouse_connect/driver/_backend/http_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from clickhouse_connect.driver.common import ShowClickHouseErrors, dict_copy
from clickhouse_connect.driver.exceptions import OperationalError, ProgrammingError
from clickhouse_connect.driver.httputil import ResponseSource, all_managers, check_conn_expiration, get_response_data
from clickhouse_connect.driver.kerberos import KerberosAuthContext

if TYPE_CHECKING:
from clickhouse_connect.driver._backend.contracts import SyncBackend
Expand Down Expand Up @@ -78,6 +79,8 @@ def __init__(
server_host_name: str | None,
token_provider: Callable[[], str] | None,
autogenerate_query_id: bool,
use_kerberos: bool = False,
kerberos_hostname: str | None = None,
http_retries: int = 1,
read_format: str = "Native",
form_encode_query_params: bool = False,
Expand All @@ -94,6 +97,8 @@ def __init__(
self.timeout = timeout
self.server_host_name = server_host_name
self.token_provider = token_provider
self.use_kerberos = use_kerberos
self.kerberos_hostname = kerberos_hostname
self.autogenerate_query_id = autogenerate_query_id
self.http_retries = http_retries
self.read_format = read_format
Expand Down Expand Up @@ -306,7 +311,12 @@ def request(
final_params["query_id"] = str(uuid.uuid4())

url = f"{self._base_url}?{urlencode(final_params)}"
kwargs: dict[str, Any] = {"headers": headers, "timeout": self.timeout, "retries": self.http_retries, "preload_content": not stream}
kwargs: dict[str, Any] = {
"headers": headers,
"timeout": self.timeout,
"retries": 0 if self.use_kerberos else self.http_retries,
"preload_content": not stream,
}
if self.server_host_name:
kwargs["assert_same_host"] = False
kwargs["headers"].update({"Host": self.server_host_name})
Expand All @@ -328,6 +338,11 @@ def request(
# throw an error instead, but in most cases this more helpful error will be thrown first
self._active_session = query_session
try:
kerberos_context = None
if self.use_kerberos:
assert self.kerberos_hostname is not None
kerberos_context = KerberosAuthContext(self.kerberos_hostname)
headers["Authorization"] = kerberos_context.authorization_header
Comment on lines +342 to +345
response: HTTPResponse = cast(HTTPResponse, cast(PoolManager, self.http).request(method, url, **kwargs))
except HTTPError as ex:
# Always allow at least one retry on a clean connection error so a single stale
Expand Down Expand Up @@ -360,11 +375,18 @@ def request(
if query_session:
self._active_session = None # Make sure we always clear this
if 200 <= response.status < 300 and not response.headers.get(ex_header):
if kerberos_context is not None:
try:
kerberos_context.validate_response(response.headers.get("WWW-Authenticate"))
except Exception:
response.close()
raise
return response
if response.status in retryable_http_statuses:
if attempts > retries:
self.error_handler(response, True)
logger.debug("Retrying requests with status code %d", response.status)
response.close()
elif self.token_provider and not auth_retried and response.headers.get(ex_header) == auth_failed_ex_code:
body = kwargs.get("body")
if retry_body is None and not (body is None or isinstance(body, (bytes, bytearray, str))):
Expand Down
Loading