# Haven > An enterprise secret manager with HashiCorp Vault feature parity. Async Python on > PostgreSQL, multi-tenant by row-level security, sealed by Shamir's secret sharing. > Haven holds other systems' credentials, so its refusals are deliberate and it fails > closed by design. API base: https://haven.rodmena.co.uk/v1 Dashboard: https://haven.rodmena.co.uk/ (humans, OIDC sign-in) Python SDK: pip install haven-client This document is the agent reference. Read the three sections under "Read this first" before writing any code against Haven; each one describes a behaviour that looks like an outage the first time you meet it. --- ## Read this first ### 1. Every route answers 503 after a restart, and that is not a fault Haven starts SEALED. Its master key exists only in process memory and is reconstructed from three of five Shamir shares held by operators. Until someone unseals it, every route answers: 503 {"errors":["Haven is sealed"]} Unsealing is manual, by design, and is not something a client can trigger or wait out. Treat 503 with "sealed" in the message as "an operator must act", not as a retry loop. Check without a token: GET /v1/sys/seal-status -> {"initialized":true,"sealed":false,"t":3,"n":5,"progress":0,...} `GET /readyz` and `GET /healthz` also answer without a token, and both answer HEAD with the same status as GET. ### 2. Your tenant comes from your credential. You cannot ask for one Haven is multi-tenant. A tenant is the scoped space: its own encryption key, its own mounts, its own policies, its own secrets, enforced in PostgreSQL by row-level security. **The tenant is resolved from the token you present and from nothing else.** The `X-Haven-Namespace` header may only CONFIRM the tenant your token already resolves to; a mismatch is 403. It cannot select one. If a header could choose the tenant, an authorization decision would live in client-controlled input. So there is no "switch tenant" call. To act in another tenant, authenticate to it. ### 3. Statuses mean what they say, and 500 is never your fault 200 ok, enveloped body 204 ok, no body at all (do not parse it) 400 your request is malformed 401 your credential is not valid 403 your credential is valid and your policy does not allow this 404 no such object (also returned instead of 403 where existence itself is secret) 405 the engine does not support that operation 409 a conflict: slug in use, CAS mismatch, concurrent rotation 413 body too large 429 rate limited at the proxy 500 a defect in Haven. Report it; do not design around it 503 sealed, or a dependency is unavailable. Read the message to tell which An anonymous caller cannot distinguish a real route from a missing one: both answer 403. This is deliberate, so do not use status codes to discover the API surface. Use this document. --- ## The envelope Every successful JSON response has the same shape. Fields you did not earn are null. { "request_id": "3f2c...", quote this when reporting anything "lease_id": "", non-empty when the response carries a lease "renewable": false, "lease_duration": 0, seconds "data": { ... }, the payload, or null "wrap_info": null, set instead of data when you asked for wrapping "warnings": null, "auth": null set by login routes: carries client_token } Errors are always: {"errors": ["a human sentence", "..."]} --- ## Authenticating Send the token on every request: X-Haven-Token: hvn.0.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx `Authorization: Bearer ` also works. `X-Vault-Token` is accepted for Vault-client compatibility but is deprecated; use `X-Haven-Token` in new code. A token that cannot be resolved — malformed, unknown, expired, revoked, or naming a key version that does not exist — is 401. It is never 500. **Every rejected credential is recorded in the audit trail**, with the reason, against the tenant it resolved to. A request carrying no token at all is refused without a record, deliberately: that is a port scan, and auditing it would let anyone append to a tamper-evident log at will. A token may be bound to networks with `bound_cidrs` at creation, and the binding is enforced against your source address on every request. A source address Haven cannot read fails closed. ### AppRole — the method for applications Two halves, deliberately distributed by different routes. - `role_id` stable, not a secret. Ship it in config or the image. Like a username. - `secret_id` the credential. Issued separately, revocable, optionally single-use and time-limited. Operator sets it up once, in the tenant: POST /v1/sys/auth/approle {"type": "approle"} POST /v1/auth/approle/role/billing-api {"token_policies": ["billing-read"], "token_ttl": 3600, "token_max_ttl": 28800, "secret_id_ttl": 600, "secret_id_num_uses": 1, "bind_secret_id": true} GET /v1/auth/approle/role/billing-api/role-id -> {"data":{"role_id":"..."}} POST /v1/auth/approle/role/billing-api/secret-id -> {"data":{"secret_id":"...","secret_id_accessor":"..."}} The application logs in. This route takes no token: POST /v1/auth/approle/login {"role_id":"...","secret_id":"..."} -> {"auth":{"client_token":"hvn.0...","policies":["billing-read","default"], "lease_duration":3600,"renewable":true}} A wrong role_id and a wrong secret_id fail identically, so the endpoint will not tell you whether a role exists. ### Kubernetes — for pods, no secret_id to distribute POST /v1/auth/kubernetes/login {"role":"billing-api","jwt":""} The pod presents its own service-account JWT and Haven verifies it against the cluster. NOTE: this method is implemented but has not been exercised against a real cluster on this deployment. AppRole has. Prefer AppRole until that changes. ### OIDC — for humans Used by the dashboard. Haven owns the state, nonce, PKCE verifier and client secret; a browser-facing process holds none of them. POST /v1/auth/oidc/oidc/auth_url {"role":"dashboard","redirect_uri":"..."} POST /v1/auth/oidc/oidc/callback {"role":"dashboard","state":"...","code":"..."} ### Managing your own token GET /v1/auth/token/lookup-self who am I, what can I do, when do I expire POST /v1/auth/token/renew-self {"increment": 3600} POST /v1/auth/token/revoke-self on clean shutdown, always Create a network-bound token: POST /v1/auth/token/create {"policies":["billing-read"], "ttl":3600, "bound_cidrs":["10.0.0.0/8"]} Renew at about two thirds of the TTL. The SDK does this for you. --- ## Reading and writing secrets The key-value engine is Vault KV version 2, mounted at `secret` in every new tenant. Note the `data/` and `metadata/` segments — they are part of the path, not decoration. Write: POST /v1/secret/data/billing/postgres {"data": {"username":"billing_ro","password":"..."}} Read: GET /v1/secret/data/billing/postgres -> {"data":{"data":{"username":"billing_ro","password":"..."}, "metadata":{"version":3,"created_time":"...","destroyed":false}}} The payload is nested: `.data.data` holds your fields, `.data.metadata` holds the version record. Read an older version with `?version=2`. List, which is a GET with a query parameter: GET /v1/secret/metadata/billing?list=true -> {"data":{"keys":["postgres","stripe","smtp/"]}} A key ending in `/` is a folder, not a secret. Delete the current version, soft: DELETE /v1/secret/data/billing/postgres Compare-and-set, to make a concurrent overwrite fail rather than win: POST /v1/secret/data/billing/postgres {"data": {...}, "options": {"cas": 3}} -> 409 if the current version is not 3, and the message names the version that IS current, which is what you retry against --- ## Prefer credentials that expire A static password in KV is a password that leaks eventually. Haven can mint one per process instead, with a lease that reclaims it. GET /v1/database/creds/billing-ro -> {"lease_id":"database/creds/billing-ro/xxxx", "lease_duration":3600,"renewable":true, "data":{"username":"v-billing-ro-a1b2","password":"..."}} The credential is created in the target database and dropped when the lease expires or is revoked. Measured expiry latency on this deployment is under one second. POST /v1/sys/leases/renew {"lease_id":"...","increment":3600} POST /v1/sys/leases/revoke {"lease_id":"..."} GET /v1/sys/leases/count Revoke on shutdown. A process that exits without revoking leaves a credential alive until its lease runs out. --- ## Other engines Transit — encryption as a service. Your plaintext is encrypted by a key that never leaves Haven, so your application never holds one. POST /v1/transit/encrypt/orders {"plaintext":""} -> {"data":{"ciphertext":"vault:v1:..."}} POST /v1/transit/decrypt/orders {"ciphertext":"vault:v1:..."} Also: rewrap, datakey, sign, verify, hmac, random, hash. Rotating a key re-encrypts nothing: every ciphertext names the version that made it, so old ciphertext stays readable and new ciphertext uses the new version. PKI — issue a short-lived certificate: POST /v1/pki/issue/internal {"common_name":"billing.internal","ttl":"72h"} A role's `allowed_domains` constrains DNS names only; an IP address cannot be matched against a domain list. `allow_ip_sans` therefore defaults to **false** and must be asked for, or a role confined to one domain would also issue for any address. Cubbyhole — a private path scoped to one token, destroyed when that token dies. --- ## Delivering a credential without it lying around Response wrapping. Ask for it with a header, and Haven returns a single-use wrapping token instead of the payload: POST /v1/auth/approle/role/billing-api/secret-id X-Haven-Wrap-TTL: 300 -> {"wrap_info":{"token":"hvn.0...","ttl":300,"creation_path":"..."}} POST /v1/sys/wrapping/unwrap {"token":"hvn.0..."} -> the secret_id, once Hand the wrapping token to your deployment; the application unwraps it at boot. If anyone unwrapped it first, your unwrap FAILS. That is the point: interception becomes detectable instead of silent. Routes that refuse to be wrapped say so rather than quietly returning the plaintext. A TTL of `0` or less is a 400, not a silent "never mind": a caller that asked for wrapping and was handed the plaintext would have no way to know it happened. --- ## Policies A policy is paths to capabilities. JSON is the default; HCL is accepted with `"format":"hcl"`. POST /v1/sys/policies/acl/billing-read {"policy": "{\"path\": { \"secret/data/billing/*\": {\"capabilities\": [\"read\"]}, \"secret/metadata/billing/*\": {\"capabilities\": [\"list\",\"read\"]}, \"secret/data/billing/root-*\":{\"capabilities\": [\"deny\"]} }}"} Capabilities: create, read, update, delete, list, sudo, deny, patch, subscribe. Rules: - `*` matches a prefix, only at the end. `+` matches exactly one path segment. - The most specific rule wins, and a literal path beats a glob. - `deny` beats everything at the same specificity. There is no way to override it. - Reading needs `read` on `secret/data/`. LISTING needs `list` on `secret/metadata/`. Granting one does not grant the other, and this is the single most common cause of an unexpected 403. Check before you guess: POST /v1/sys/capabilities-self {"paths":["secret/data/billing/postgres"]} -> {"data":{"secret/data/billing/postgres":["read"]}} Every token also carries `default`. `root` bypasses policy entirely and should not be held by an application. --- ## Python SDK pip install haven-client ```python from haven_client import HavenClient haven = HavenClient("https://haven.rodmena.co.uk") # There is no approle_login helper yet. Log in with write() and set the token. auth = haven.write("/v1/auth/approle/login", {"role_id": role_id, "secret_id": secret_id}).auth haven.token = auth["client_token"] haven.start_renewal(ttl_seconds=auth["lease_duration"]) password = haven.read_secret("billing/postgres").data["password"] cred = haven.database_credentials("billing-ro") connect(user=cred.username, password=cred.password) haven.revoke_lease(cred.lease_id) haven.close() # stops renewal and closes the pool ``` `HavenClient` is thread-safe and connection-pooled: one per process is the intended shape. `AsyncHavenClient` in `haven_client.aio` has the same surface with `await`. Both retry idempotent reads; neither retries a write. The constructor takes `base_url` first, then `token`, and keyword-only `namespace`, `timeout_s`, `verify`, `retry` and `cache_ttl_s`. --- ## Losing a credential Root tokens can be replaced. Ask your administrator: POST /v1/sys/tenants//rotate-root (system tenant only) {"revoke_descendants": false} This issues your tenant a fresh root and retires the old one. Tokens the old root created keep working unless `revoke_descendants` is set — set it if you believe the root leaked, because a compromised root may have minted tokens of its own. The **system** root is replaced by a ceremony rather than by an API call, because nothing has authority above it: `sys/generate-root/attempt`, then `sys/generate-root/update` with threshold-many unseal shares, which are combined and checked against the running master key. It is loopback-only, like unseal. ## Getting an account Haven does not self-serve. A tenant is created by a platform administrator from the system tenant, and the tenant root token is returned exactly once at creation and is never retrievable afterwards. Ask farshid@rodmena.co.uk. Once you have a tenant, your administrator enables an auth method and issues your application a role — you do not need the root token to run an application, and should not hold it. --- ## Things that will bite you - **503 on every route** means sealed, not down. An operator must unseal. It happens after every restart, by design. - **`GET /v1/secret/billing/postgres` returns nothing useful.** KV v2 needs `secret/data/...`. The path without `data/` is a different address. - **List needs `metadata`, read needs `data`.** A policy granting only one produces a 403 that looks like the secret does not exist. - **The payload is nested twice.** `response.data.data` holds your fields. - **204 has no body.** Do not parse it. - **A namespace header cannot switch tenants.** It can only agree with your token. - **The OpenAPI document does not describe the engines.** `/v1/sys/openapi.json` requires a token and covers `sys` and the auth methods only; KV, transit, PKI and database are dispatched by one catch-all and appear in no schema. This document is the reference for those. - **Do not poll `lookup-self` as a health check.** Use `/readyz`, which needs no token. - **`GET /version` reports the running commit.** Use it to tell what is actually deployed rather than what was merged.