Metadata-Version: 2.5
Name: ratelkey-burrow-sdk
Version: 1.4.1
Summary: Read secrets from a self-hosted RatelKey Burrow with a machine identity.
Project-URL: Homepage, https://ratelkey.com
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: burrow,ratelkey,sdk,secrets
Requires-Python: >=3.9
Requires-Dist: cryptography>=41.0.0
Description-Content-Type: text/markdown

# Burrow SDK for Python

Read secrets from a self-hosted [RatelKey](https://ratelkey.com) Burrow with a machine identity.

- Single and structured secrets, and individual fields of a structured secret.
- The machine's own access map (`list_projects`, `list_secrets`).
- Certificate pinning, or standard CA trust (`TlsMode`).
- Live secrets that follow every change on the Burrow (`live`, `live_environment`).
- An encrypted copy of each read that answers when the Burrow can't be reached (`cache_enabled`).
- Certificates from a dynamic certificate secret, such as SSH logins, for a key made in memory (`get_certificate`, `live_certificate`).
- An OIDC token instead of a key (`Burrow.oidc`).

## Requirements

- Python 3.9+.
- One dependency: [`cryptography`](https://pypi.org/project/cryptography/) (Ed25519 signing, the certificate pin, and the cache's encryption).

## Install

```
pip install ratelkey-burrow-sdk
```

## Bootstrap a machine

From the Burrow dashboard, **Machines → Add machine**, and run the install it gives you on the target host.
The identity lands at `~/.burrow/burrows/<slug>/identity.json`.

## Quick start

```python
from ratelkey_burrow import Burrow

# The single machine identity on this host (under ~/.burrow, or $BURROW_HOME).
burrow = Burrow()

# A single-value secret:
api_key = burrow.get_secret("payments/prod/API_KEY").value

# A structured secret — one read, projected locally (no re-fetch):
db = burrow.get_secret("payments/prod/DATABASE")
everything = db.to_dict()                                  # every field as name -> value
host = db.get_field("host")                                # one field
user, password = db.get_fields("username", "password")     # in order

# What this machine can read:
for p in burrow.list_projects():
    print(p.project, p.categories)
```

Point it at a specific identity by selector or path:

```python
Burrow("burrow-a1b2c3d4e5f60718")                            # slug / name / machine id / URL
Burrow("/home/you/.burrow/burrows/burrow-a1b2c3d4e5f60718")  # path (a burrow dir or identity.json)
```

## Without `~/.burrow`

For an app that manages its own key material:

```python
import os
from ratelkey_burrow import Burrow

burrow = Burrow.connect(
    burrow_url="https://burrow.internal:12010",
    machine_id="...",
    private_key_pem=os.environ["BURROW_MACHINE_KEY"],  # PKCS#8 PEM
)
```

## Live secrets

A live handle holds a secret's current value and follows every change on the Burrow as it happens.

```python
db = burrow.live("payments/prod/DATABASE")

db.get_field("password")                                    # always the current value
db.on_change(lambda next, prev: pool.reconnect(next))
```

`burrow.live_environment("payments/prod")` does the same for a whole environment: `env.get("API_KEY")`, and
`on_change` receives what changed, was added or was removed.

- The call returns after the first read. A missing secret or grant is refused there.
- All live handles on a client share one connection on a background thread. It reconnects on its own and re-reads
  anything that changed while it was down.
- `status` is `live`, `reconnecting`, `revoked` (access was taken away) or `removed` (deleted on the Burrow). The
  last two are final, and the handle keeps its last value.
- Listeners run on the SDK's threads. One that raises is reported to `on_error`.
- `burrow.close()` closes every handle.
- Needs a Burrow with live updates; an older one is refused at `live()`.

## Fallback cache

The fallback cache keeps a copy of each secret the client reads, and answers with it when your Burrow can't be reached.
It's off until you turn it on:

```python
burrow = Burrow().cache_enabled(True)
```

Without `cache_enabled`, `Burrow()` uses the cache when `ratel cache enable` turned it on for the machine, and
`cache_enabled(False)` keeps it off. With `Burrow.connect`, it needs a `pin_store_dir` to keep the copies in.
`Burrow.enroll` and `Burrow.oidc` can't use it.

A read uses the cache only when it fails because your Burrow can't be reached or can't answer. A secret answered from
the cache says when its copy was saved:

```python
key = burrow.get_secret("payments/prod/API_KEY")
if key.cached_at:
    log.warning("using the copy saved at %s", key.cached_at)
```

A live secret or environment doesn't answer from the cache. While your Burrow can't be reached it keeps the value it
last read, and each read it makes keeps the copies up to date. When your Burrow refuses a read, because the machine was
removed or disabled, lost access, or the secret is gone, the read fails as usual and the refused copies are deleted. A
dynamic secret's login comes back as it was saved, so it may have expired.

## Certificates

A dynamic certificate secret, such as an SSH user secret, gives each machine that asks a short-lived certificate of
its own. The SDK makes a new Ed25519 key in memory for each one and sends only its public half.

```python
login = burrow.get_certificate("api/prod/DEPLOY_SSH")

login.private_key     # the OpenSSH private key
login.certificate     # its certificate (ssh-ed25519-cert-v01@openssh.com ...)
login.principals      # the logins it signs in as
login.host            # where the secret says to connect, or None
login.valid_before    # when it stops being accepted (UTC)
```

Hand the key and certificate to your SSH library, or write them to `id_ed25519` (mode 600) and
`id_ed25519-cert.pub` for `ssh -i`. `validity=timedelta(minutes=5)` (or a number of seconds) asks for one that lasts
less than the secret allows.

`live_certificate` keeps one current:

```python
login = burrow.live_certificate("api/prod/DEPLOY_SSH")

login.current                                               # the certificate and key to use now
login.on_change(lambda next, prev: connections.use_key(next))
login.renew()                                               # a new one at once, e.g. after a host refused it
```

- It renews once two thirds of the certificate's time have passed, and as soon as the secret's settings change.
  Each renewal makes a new key.
- Revoking one certificate from the dashboard sends no event; `renew()` gets a new one.
- `get_secret` on a certificate secret raises `BurrowServerError` (409) naming `get_certificate`.

## In-memory identity

Enroll with a machine token's code. The key is generated in memory and never written to disk; the code sets how the Burrow's certificate is trusted.

```python
import os
from ratelkey_burrow import Burrow

with Burrow.enroll(os.environ["BURROW_ENROLL_CODE"]) as burrow:
    api_key = burrow.get_secret("payments/prod/API_KEY").value
```

The machine is enrolled on the first read. Shortly before it expires, the SDK enrolls a fresh one with the same code and retires the old one. `close()` (or leaving the `with` block) expires the machine at once.

`Burrow()` enrolls from `BURROW_ENROLL_CODE` when the host has no identity under `~/.burrow`. A one-time invitation code is refused; use a machine token.

## OIDC

```python
import os
from ratelkey_burrow import Burrow

burrow = Burrow.oidc(
    burrow_url="https://burrow.example.com",
    fingerprint=os.environ.get("BURROW_FINGERPRINT"),  # omit for a publicly-trusted certificate
)

api_key = burrow.get_secret("payments/prod/API_KEY").value
```

`audience` defaults to `burrow_url`. GitHub Actions requires `permissions: id-token: write`. GitLab CI/CD (gitlab.com) requires the job to declare its token as `RATEL_ID_TOKEN`:

```yaml
id_tokens:
  RATEL_ID_TOKEN:
    aud: https://burrow.example.com
```

Bitbucket Pipelines requires the step to list the Burrow as an OIDC audience:

```yaml
- step:
    oidc:
      audiences:
        - https://burrow.example.com
```

## Certificate trust

```python
from ratelkey_burrow import Burrow, TlsMode

burrow = Burrow(tls=TlsMode.SystemTrust)
```

Without an explicit `tls`, the SDK uses the trust chosen when the machine was added: encrypted by default (works everywhere), or verified against the Burrow's own key or a CA if that was selected.

- `TlsMode.Pinned` — trust-on-first-use, stored at `~/.burrow/burrows/<slug>/known_cert`.
- `TlsMode.PinnedTo("aa:bb:...")` — a fixed fingerprint (from the Burrow's network settings).
- `TlsMode.SystemTrust` — CA + hostname verification, for a Burrow on a custom domain or Let's Encrypt.
- `TlsMode.Insecure` — no verification; accepts any certificate. Opt-in only, for a network you already trust. Shortcut: `Burrow().insecure_tls(True)`.

## Webhooks

Verify a delivery from a Burrow's Signed webhook. Needs only the webhook's signing secret, not a machine identity.

```python
import os
from flask import Flask, request
from ratelkey_burrow import verify_webhook

app = Flask(__name__)

@app.post("/hooks/burrow")
def burrow_hook():
    event = verify_webhook(request.get_data(), request.headers, os.environ["BURROW_WEBHOOK_SECRET"])
    print(event.event, event.data.summary)
    return "", 204
```

Pass the raw body, before JSON parsing (`request.body` in Django). A retry of the same event carries the same `event.id`, so keep the ids you've handled to ignore repeats.

## Errors

Every failure is a `BurrowError`: `BurrowAuthError` (401), `BurrowForbiddenError` (403, no grant),
`BurrowNotFoundError` (404), `BurrowServerError` (5xx, or 409 when a secret refuses the request),
`BurrowCertificateError` (pin mismatch),
`BurrowTransportError` (unreachable / unparsable), `BurrowIdentityError` (no/ambiguous/invalid identity),
plus two projection errors on the result handle — `SecretShapeError` (e.g. `.value` on a structured secret)
and `FieldNotFoundError`. The message carries the Burrow's own reason. `verify_webhook` raises
`BurrowWebhookError` for a missing or wrong signature.