Metadata-Version: 2.5
Name: haven-client
Version: 1.0.0
Summary: Python client for Haven, the Vault-compatible secret manager: KV, transit, PKI, database credentials, AppRole and Kubernetes login, with token renewal and safe retries.
Project-URL: Homepage, https://haven.rodmena.co.uk
Project-URL: Documentation, https://haven.rodmena.co.uk/llms.txt
Project-URL: Changelog, https://github.com/rodmena-limited/haven-client/blob/main/CHANGELOG.md
Author-email: Rodmena Limited <info@rodmena.co.uk>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: approle,haven,kubernetes,pki,secrets,transit,vault
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: httpx<1,>=0.27
Requires-Dist: resilient-circuit<1,>=0.8.4
Description-Content-Type: text/markdown

# haven-client

The Python client for [Haven](https://haven.rodmena.co.uk), Rodmena's Vault-compatible secret manager.

```bash
pip install haven-client
```

It needs Python 3.12 or newer. Its only dependencies are `httpx` and `resilient-circuit`.

## Five lines

```python
from haven_client import HavenClient

with HavenClient(address="https://haven.rodmena.co.uk", token="hvn.0...") as haven:
    haven.kv.write("billing/db", {"username": "svc", "password": "s3cr3t"})
    print(haven.kv.read("billing/db").data["username"])
```

## Configuration from the environment

`HavenClient()` with no arguments reads the environment. The `VAULT_*` names are accepted too, so existing deployment manifests keep working.

| Variable | Meaning |
|---|---|
| `HAVEN_ADDR` / `VAULT_ADDR` | Base URL, e.g. `https://haven.rodmena.co.uk` |
| `HAVEN_TOKEN` / `VAULT_TOKEN` | A token, if you already hold one |
| `HAVEN_TOKEN_FILE` | A file holding the token. Read once at start |
| `HAVEN_NAMESPACE` / `VAULT_NAMESPACE` | Tenant slug. Haven uses it to *confirm* a tenant, never to select one |
| `HAVEN_CACERT` / `VAULT_CACERT` | CA bundle used to verify Haven's certificate |
| `HAVEN_CLIENT_CERT`, `HAVEN_CLIENT_KEY` | Client certificate for mTLS (the `VAULT_*` names are read too) |

TLS verification is always on. You can turn it off only in code, with `TlsConfig(verify=False)`; no environment variable can switch it off, so a typo in a manifest can never quietly disable it.

## Logging in

### Kubernetes

The pod's service-account token is the credential. Nothing long-lived is stored in the pod.

```python
haven = HavenClient.with_kubernetes(role="billing-api")
haven.start_lifecycle()
password = haven.kv.read("billing/db").data["password"]
```

- The client reads `/var/run/secrets/kubernetes.io/serviceaccount/token` again at every login. Kubernetes rotates projected tokens, so a cached copy would expire.
- The Haven role must name an `audience`, and the pod's projected token must carry that same audience:

```yaml
volumes:
  - name: haven-token
    projected:
      sources:
        - serviceAccountToken:
            path: token
            audience: haven
            expirationSeconds: 3600
```

```python
haven = HavenClient.with_kubernetes(role="billing-api", token_path="/var/run/secrets/haven/token")
```

### AppRole

For a service that does not run on Kubernetes. Keep the secret id in a file readable only by the service user (mode 0400). The client reads it at each login and never keeps it.

```python
haven = HavenClient.with_approle(role_id="...", secret_id_file="/etc/billing/haven-secret-id")
haven.start_lifecycle()
```

If the role sets `secret_id_bound_cidrs`, that file is useless off the hosts it was issued for.

### A wrapped secret id

When the secret id is delivered response-wrapped, unwrap it once, with the wrapping token itself as the credential:

```python
bootstrap = HavenClient(address=addr)
secret_id = bootstrap.wrapping.unwrap(wrapping_token).data["secret_id"]
```

## Token lifecycle

`start_lifecycle()` logs in, then runs a background thread that:

- renews the token at two thirds of the `lease_duration` Haven returned, with jitter;
- logs in again when a renewal is refused or comes back too short (the token reached its maximum TTL);
- records the failure when both renewal and re-login fail, in `haven.last_auth_error`, and calls `on_auth_error`. It never fails silently:

```python
def alarm(exc: Exception) -> None:
    metrics.increment("haven_auth_failures")

haven = HavenClient.with_kubernetes(role="billing-api", on_auth_error=alarm)
```

## Engines

```python
haven.kv.read(path, version=None)        # KV v2 at "secret"; haven.kv_v2("other") for another mount
haven.kv.write(path, data, cas=0)        # cas=0: only if the path does not exist yet
haven.kv.patch(path, data)
haven.kv.metadata(path)
haven.kv.list(prefix)
haven.kv.delete_versions(path, [1])      # soft delete, can be undeleted
haven.kv.destroy_versions(path, [1])     # permanent
haven.kv_v1("kv").read(path)

haven.transit.encrypt("orders", b"...")
haven.transit.decrypt("orders", ciphertext)
haven.transit.sign("jws", payload, hash_algorithm="sha2-256", signature_algorithm="pkcs1v15")  # RS256
haven.transit.verify("jws", payload, signature, signature_algorithm="pkcs1v15")

haven.pki.issue("internal", "billing.internal", ttl="72h")
haven.database.credentials("reader")     # a fresh user and password, with a lease
haven.leases.renew(lease_id, increment="1h")
haven.sys.is_sealed()
```

Anything not covered by a helper is still reachable with `haven.request(method, path, json_body=..., params=...)`.

## Retries: what the client will and will not repeat

- **Retried:** idempotent requests only (GET, LIST, HEAD and the explicitly idempotent reads such as `transit.verify`), on a network error, a timeout, 429, 502, 504, or a 503 for storage being unavailable. Waits use exponential backoff with jitter, and honour `Retry-After` up to 60 s. Retries go through `resilient-circuit`.
- **Never retried:** writes (POST, PUT, PATCH, DELETE) are sent exactly once, because a repeated write can issue a second certificate or create a second database user. A write that fails raises, and you decide.
- **Not retried by default:** a **sealed** Haven. A sealed Haven needs an operator to unseal it, and retrying cannot change that. Raise `Sealed` straight away, or opt in with `RetryConfig(retry_sealed=True)`.

## Errors

| Exception | When |
|---|---|
| `HavenClientError` | Base of everything below |
| `TransportError` | No HTTP answer: DNS, TLS, connect, timeout. The cause is chained |
| `HavenApiError` | Haven answered with an error. Carries `.status_code`, `.code`, `.errors` and `.request_id` |
| `BadRequest` | 400 |
| `InvalidCredentials` | 401 |
| `PermissionDenied` | 403 |
| `NotFound` | 404 |
| `Conflict` (409), `CasMismatch` | A check-and-set lost to another writer |
| `RateLimited` | 429 |
| `Unavailable` (503) | Subclasses: `Sealed`, `StorageUnavailable` |
| `Uninitialized` | 501 |
| `ServerError` | Other 5xx |
| `AuthenticationFailed` | A login, or the lifecycle, could not obtain a token |

Quote `.request_id` when reporting a problem: it is the same id Haven's audit log records.

## Asynchronous use

```python
from haven_client import AsyncHavenClient

async with AsyncHavenClient(address=addr, token=token) as haven:
    secret = await haven.kv.read("billing/db")
```

The async client runs the same code as the synchronous one in a worker thread, so their behaviour cannot drift apart.

## Secrets never leak through `repr`

Tokens, secret ids, database passwords and private keys print as `<redacted>` in `repr()`, `str()` and log records. The value is still there: call `.reveal()` on a `SecretString` when you mean to use it.

## Compatibility

- Tested against Haven 0.1.0 with the server change from 2026-10 (machine-readable error `code`).
- Older servers work too. Without `code` the client tells sealed from storage-down by the message text.
- See [CHANGELOG.md](CHANGELOG.md) for what changed from 0.1.
