Skip to content
Draft
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
38 changes: 38 additions & 0 deletions Documentation/authentication-and-tls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
title: Authentication and TLS
description: How the Chronicle Python client obtains and refreshes OAuth tokens, which certificates it trusts, and what happens when authentication fails.
---

## Token handling

`OAuthTokenProvider` posts `grant_type=client_credentials`, `client_id` and `client_secret` as
`application/x-www-form-urlencoded` to `/connect/token` on the kernel's own host and port. The channel asks the
provider for the current token when each new gRPC call starts and sends it as `authorization: Bearer <token>`; no
token is baked into the channel.

- A token is reused until shortly before it expires: half of its lifetime for short tokens, otherwise 30 seconds
early.
- When the response has no `expires_in`, the token is trusted for 30 seconds only.
- Concurrent callers share one request. Cancelling one caller does not cancel the request the others wait for.
- A response that is not JSON, has no `access_token`, a `token_type` other than `Bearer`, or an invalid `expires_in`
raises `TokenResponseError`. HTTP 400, 401 and 403 raise `TokenAuthorizationError` with the OAuth `error` and
`error_description`. Redirects are never followed, so the secret only goes to the configured host.
- The client secret and tokens are excluded from `repr()` and from every error message, including text the server
echoes back.
- A call that receives `UNAUTHENTICATED` is not retried. Reconnect and retry behavior is not part of this milestone.

## Certificate trust

Certificates and host names are verified for every connection. `skipTlsValidation` is `true` by default in the
connection string, as in the .NET client; this client honors it with these rules:

| Situation | Trusted certificate |
| --- | --- |
| `ca_certificates=<PEM bytes>` is passed to `ChronicleClient.connect` | Exactly those roots |
| `skipTlsValidation` is `true` and the host is `localhost` or a loopback address | The certificate the local kernel presents, read once at connect time; the host name is still checked |
| `skipTlsValidation=false` | The platform's default roots |
| `skipTlsValidation` is `true` and the host is **not** loopback | The platform's default roots. The option is ignored |

A remote kernel is therefore never reached with relaxed validation. To reach a remote kernel with a private
certificate authority, pass its certificate as `ca_certificates`. A loopback kernel with a self-signed certificate
works with the defaults; add `?skipTlsValidation=false` to prove it fails without trust.
61 changes: 42 additions & 19 deletions Documentation/client-development-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ Chronicle C# contracts
### Temporary contracts distribution

`cratis-chronicle-contracts` is not published to PyPI. The project dependency resolves the verified
`cratis-chronicle-contracts` 16.38.2 wheel from its matching
[Chronicle GitHub release](https://github.com/Cratis/Chronicle/releases/tag/v16.38.2), pinned by SHA-256 in
`cratis-chronicle-contracts` 19.31.3 wheel from its matching
[Chronicle GitHub release](https://github.com/Cratis/Chronicle/releases/tag/v19.31.3), pinned by SHA-256 in
`pyproject.toml`. A normal development install fetches it automatically, so the install needs network access to
GitHub release assets. Do not copy generated contracts into this repository.

The PyPI trusted-publishing setup ([#15](https://github.com/Cratis/Chronicle.Python/issues/15)) and first
publication ([#16](https://github.com/Cratis/Chronicle.Python/issues/16)) were closed as not planned, so there is
no scheduled move to PyPI. The contracts wheel is generated from the v16.38.2 kernel; see
[Kernel version](#kernel-version) before testing against a newer kernel.
no scheduled move to PyPI. The contracts wheel is generated from the v19.31.3 kernel; see
[Kernel version](#kernel-version) for the kernels it works with.

## Local kernel

Expand All @@ -41,7 +41,7 @@ built-in development client credentials.
Start the development image that matches the contracts version, bound to the loopback interface only:

```shell
docker run --rm -p 127.0.0.1:35000:35000 cratis/chronicle:16.38.2-development
docker run --rm -p 127.0.0.1:35000:35000 cratis/chronicle:19.31.3-development
```

The development image embeds MongoDB inside the container, so every event disappears when the container stops.
Expand All @@ -57,10 +57,12 @@ configuration contract.

### Kernel version

The contracts and the probe below were exercised against `cratis/chronicle:16.38.2-development`. Newer kernels,
including `latest-development`, may add or change contracts; compatibility between the 16.38.2 contracts and a
later kernel has not been verified. Name the exact kernel image in any issue, test, or pull request that exercises
network behavior.
The contracts, the probe below and the integration tests were exercised against
`cratis/chronicle:19.31.3-development`. The client calls the 19.x contract names (`EventTypes.RegisterEventTypes`,
`EventSequences.Append` in the `Sequences` package), so kernels from before that rename, such as 16.38.2, are not
supported: registering an event type against them fails with `UNIMPLEMENTED`. Kernels newer than 19.31.3, including
`latest-development`, may add or change contracts and have not been verified. Name the exact kernel image in any
issue, test, or pull request that exercises network behavior.

## Authentication contract

Expand All @@ -85,7 +87,8 @@ call as metadata:
authorization: Bearer <access token>
```

Token acquisition, caching, expiry, refresh, and call interception should remain separate from the channel. Do
Token acquisition, caching, expiry, refresh, and call interception are separate from the channel
(`token_provider.py`, `channel.py`). Do
not permanently bake one expiring token into channel headers. Chronicle's
[authentication and bearer tokens](https://www.cratis.io/chronicle/building-a-client/authentication-and-bearer-tokens/)
page describes the behavior the other clients implement: the three authentication modes selected by the connection
Expand All @@ -100,10 +103,9 @@ validation.

Chronicle's .NET client differs: it accepts any server certificate unless validation is turned on; see
[TLS configuration](https://www.cratis.io/chronicle/configuration/tls/). This guide does not adopt that default.
How the Python client exposes local-development relaxation (an explicit option, a `skipTlsValidation`
connection-string parameter, or both) is a public API decision for
[connection-string parsing](https://github.com/Cratis/Chronicle.Python/issues/2). Do not decide it implicitly in
an implementation, and do not make relaxed validation the behavior for non-local connections. The connection-string grammar, including `skipTlsValidation`, `apiKey`, and `auth=none`, is in
The Python client reads that decision from the connection string: `skipTlsValidation` is honored only for
loopback hosts, where it trusts the certificate the kernel presents, and is ignored for every other host; see
[Authentication and TLS](authentication-and-tls.md). The connection-string grammar, including `skipTlsValidation`, `apiKey`, and `auth=none`, is in
[connection string elements](https://www.cratis.io/chronicle/building-a-client/connection-string-elements/).

### Check the kernel before writing client code
Expand Down Expand Up @@ -170,7 +172,7 @@ async def main() -> None:
asyncio.run(main())
```

Run it from the activated development environment. Against `cratis/chronicle:16.38.2-development` it prints:
Run it from the activated development environment. Against `cratis/chronicle:19.31.3-development` it prints:

```text
without token: UNAUTHENTICATED
Expand All @@ -179,7 +181,28 @@ with token: CommandResult

The probe creates an event store named `python-probe` in the development kernel. Stopping the container removes it.

## First executable milestone
## Verify against a real kernel

The unit tests run an in-process fake kernel. The opt-in integration tests need a development kernel you start
yourself. Use a private port so they cannot collide with other kernels:

```shell
docker run -d --name chronicle-python-it -p 127.0.0.1:19300:35000 cratis/chronicle:19.31.3-development
# wait until the log says "ready and listening on port 35000"
CHRONICLE_INTEGRATION_URL=chronicle://localhost:19300 pytest tests/test_integration.py --no-cov
docker rm -f chronicle-python-it
```

The tests authenticate over TLS, ensure an event store and the `Default` namespace, register an event type, append
two events and assert consecutive sequence numbers. They also assert that wrong credentials fail with
`TokenAuthorizationError` without leaking the secret, and that `skipTlsValidation=false` rejects the self-signed
certificate. Without `CHRONICLE_INTEGRATION_URL` they are skipped. The kernel also keeps its data in the container,
so removing it cleans up.

The client targets the 19.x contract names. Against a 16.x kernel, ensuring an event store and a namespace works,
but event type registration returns `UNIMPLEMENTED`.

## First executable milestone (implemented)

Implement and verify this order before expanding the API:

Expand Down Expand Up @@ -238,7 +261,7 @@ uncertainty against the core contracts and kernel behavior.

| Symptom | Likely cause and fix |
| --- | --- |
| `pip install -e ".[dev]"` fails while downloading `cratis_chronicle_contracts-16.38.2-py3-none-any.whl` | The install cannot reach GitHub release assets. Allow `github.com` and `release-assets.githubusercontent.com`, where the download redirects, through your proxy or firewall |
| `pip install -e ".[dev]"` fails while downloading `cratis_chronicle_contracts-19.31.3-py3-none-any.whl` | The install cannot reach GitHub release assets. Allow `github.com` and `release-assets.githubusercontent.com`, where the download redirects, through your proxy or firewall |
| `pip` reports that hashes do not match | The downloaded wheel differs from the pinned SHA-256. Do not remove the hash; report it in an issue |
| `docker run` fails with `port is already allocated` | Another Chronicle kernel or process uses port 35000. Stop it, or publish a different host port (`-p 127.0.0.1:35100:35000`) and use that port in the connection string, the `curl` URL, and the probe's `PORT` |
| `ssl.SSLEOFError` or a refused connection right after `docker run` | The kernel is still starting. Wait until the token check succeeds, then retry |
Expand All @@ -247,8 +270,8 @@ uncertainty against the core contracts and kernel behavior.

## Next steps

- Pick up [async OAuth token handling](https://github.com/Cratis/Chronicle.Python/issues/3); connection-string
parsing is described in [Connection strings](connection-strings.md).
- Read [Getting started](getting-started.md), [Authentication and TLS](authentication-and-tls.md) and
[Connection strings](connection-strings.md).
- Read Chronicle's [Building a Chronicle client](https://www.cratis.io/chronicle/building-a-client/) guide for
the cross-client contract.
- Follow [CONTRIBUTING.md](../CONTRIBUTING.md) for the required checks before opening a pull request.
11 changes: 7 additions & 4 deletions Documentation/connection-strings.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,13 @@ Percent-encode a `:` in the client secret as `%3A`; an unencoded one raises `Inc

## TLS certificate validation

`skipTlsValidation` is a boolean that defaults to `true`, as in the .NET client: the connection always uses TLS, but the
kernel certificate is accepted without validation so a development kernel with a self-signed certificate works.
Pass `skipTlsValidation=false` to require a verifiable certificate. `true` and `false` are accepted; any other value
raises `UnsupportedOptionError`. The result is `options.skip_tls_validation`; `options.tls` stays `True` either way.
`skipTlsValidation` is a boolean that defaults to `true`, as in the .NET client. The connection always uses TLS. The
client honors the option only for `localhost` and loopback addresses, where it trusts the certificate the local
kernel presents so a development kernel with a self-signed certificate works. For any other host the certificate must
chain to a trusted root whatever the option says. Pass `skipTlsValidation=false` to require a verifiable certificate
everywhere. `true` and `false` are accepted; any other value raises `UnsupportedOptionError`. The result is
`options.skip_tls_validation`; `options.tls` stays `True` either way. See
[Authentication and TLS](authentication-and-tls.md).

```python
parse_connection_string("chronicle://localhost:35000/?skipTlsValidation=false").skip_tls_validation # False
Expand Down
108 changes: 95 additions & 13 deletions Documentation/getting-started.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,103 @@
# Getting started
---
title: Getting started
description: Install the experimental Chronicle Python client from a source checkout, authenticate to a local development kernel and append your first event.
---

Chronicle.Python does not yet expose a usable client API or published package. This page will become the
installation and first-append guide when the
[initial authenticated append milestone](https://github.com/Cratis/Chronicle.Python/issues/4) passes its tests.
This client is experimental. Its API changes without notice, nothing is published to PyPI, and it supports one
workflow: connect, authenticate, ensure an event store and namespace, register an event type and append an event.
Projections, reducers, reactors, subscriptions, reconnect handling and automatic kernel discovery are not
implemented.

## Install from a source checkout

Python 3.10 or newer is required. The install downloads the generated contracts wheel from GitHub release assets.

```shell
git clone https://github.com/Cratis/Chronicle.Python.git
cd Chronicle.Python
python -m venv .venv
source .venv/bin/activate
python -m pip install -e ".[dev]"
```

## Start a development kernel

The contracts in this client are generated from Chronicle 19.31.3, so use the matching kernel image. The development
image generates a self-signed certificate, accepts the built-in development credentials and keeps events inside the
container.

```shell
docker run --rm -p 127.0.0.1:35000:35000 cratis/chronicle:19.31.3-development
```

:::caution[Older kernels are not supported]
The client calls the 19.x gRPC contracts. Against a 16.x kernel, such as `cratis/chronicle:16.38.2-development`,
ensuring an event store and a namespace works but registering an event type fails with `UNIMPLEMENTED`, because the
kernel renamed that contract. Use a 19.31.3 or compatible kernel.
:::

## Append an event

```python
import asyncio
import uuid

from cratis_chronicle import ChronicleClient, EventTypeDefinition

BOOK_ADDED = EventTypeDefinition(
id="book-added",
schema={
"type": "object",
"properties": {"title": {"type": "string"}, "isbn": {"type": "string"}},
"required": ["title", "isbn"],
},
)


async def main() -> None:
async with await ChronicleClient.connect("chronicle://localhost:35000") as client:
event_store = await client.ensure_event_store("library")
namespace = await event_store.ensure_namespace("Default")
await event_store.register_event_type(BOOK_ADDED)

result = await namespace.event_log.append(
event_source_id=str(uuid.uuid4()),
event_type=BOOK_ADDED,
content={"title": "Event Sourcing in Python", "isbn": "978-0-00-000000-0"},
)
print(result.sequence_number)


asyncio.run(main())
```

The same program ships as [`Samples/append_event/main.py`](../Samples/README.md). Leaving the `async with` block
closes the gRPC channel and the token provider.

## Authentication and TLS

The client requests an OAuth token with the credentials from the connection string and attaches it to every new gRPC
call. See [Connection strings](connection-strings.md) for the credentials, and
[Authentication and TLS](authentication-and-tls.md) for token refresh, certificate trust and error handling.

## Errors

| Error | Meaning |
| --- | --- |
| `TokenAuthorizationError` | The token endpoint rejected the client credentials |
| `TokenRequestError` | The token request failed: network, TLS, timeout or an unexpected status |
| `TokenResponseError` | The token endpoint returned something that is not a token response |
| `CommandFailedError` | The kernel reported a failure while ensuring an event store or namespace |
| `AppendFailedError` | The kernel did not append the event |

All of them derive from `ChronicleError`. No message contains the client secret or an access token.

## What works today

| You want to… | Status |
| --- | --- |
| `pip install cratis-chronicle` from PyPI | Not possible. No package is published |
| Connect to Chronicle and append events from Python | Not possible through this package yet. It exposes `__version__` and a connection-string parser that opens no connection |
| Parse a `chronicle://` connection string | Supported. See [Connection strings](connection-strings.md) |
| Build the client from source and run its checks | Supported. See [Development setup](../README.md#development-setup) |
| Authenticate, ensure state, register an event type and append | Supported against a 19.31.3 development kernel |
| Read events, observe, project or react | Not implemented |
| Reconnect or discover a kernel automatically | Not implemented |
| Use Chronicle from another language now | Use the [.NET](https://github.com/Cratis/Chronicle), [TypeScript](https://github.com/Cratis/Chronicle.TypeScript), [Kotlin/Java](https://github.com/Cratis/Chronicle.Kotlin), or [Elixir](https://github.com/Cratis/Chronicle.Elixir) client |

## Contribute

To contribute now, follow [Building the Chronicle Python client](client-development-guide.md) and the repository
[contribution guide](../CONTRIBUTING.md). The development guide shows how to run a local kernel and check the
token and gRPC path before you write client code.
Loading