Bring Your Own Vault

Bring Your Own Vault

Every credential a mesh uses — model keys, channel tokens, connector secrets, tenants' own keys — lives in one vault per mesh, and you choose which. Use the built-in vault (encrypted in the mesh's own Redis or Postgres), or connect the secret store your security team already runs and keep governing credentials there.

One vault per mesh, chosen by you

You — the customer running the mesh — pick the vault on the Infra page or through the API. Your own customers (tenants) never connect or configure a vault: their credentials go into the vault you chose, each tenant in a namespace of its own.

  • New credentials — yours and every tenant's — are written to the vault you mark as the one that stores new credentials.
  • Only that vault is read (2.4.507), plus any vault connected read-only. After you switch to another vault, credentials in the old one — the built-in vault included — are not moved and not read. Enter each one again in the new vault; an agent that needs one waits, naming the record, until it is there.
  • The built-in vault cannot be removed: it holds the connected vaults' own sign-ins.
  • A vault is checked before it is saved: it must answer and its sign-in must work, so a vault that cannot be reached is refused while you can still fix it, not on the first message an agent sends.
  • A vault connected on one worker is picked up by every worker, with no restart.

Supported vaults

Vaulttype
HashiCorp Vaulthashicorp
OpenBaoopenbao
AWS Secrets Manageraws_secrets_manager
AWS Systems Manager Parameter Storeaws_parameter_store
Azure Key Vaultazure_key_vault
Google Secret Managergcp_secret_manager
CyberArk Conjurcyberark_conjur
Delinea Secret Serverdelinea
Akeylessakeyless
Dopplerdoppler
Infisicalinfisical
1Password Connectonepassword_connect
IBM Cloud Secrets Manageribm_secrets_manager
Fortanix DSMfortanix_dsm

The exact settings and sign-in modes each one takes are served by GET /api/infra/vaults/types, in the same form the Infra page renders.

Tenants push credentials; nobody sees values

A tenant sends its credentials through the API with its X-Tenant-ID header, and they are stored in that tenant's own namespace of your vault:

POST /api/infra/connections
X-API-Key: <your org key>
X-Tenant-ID: acme-corp

No route ever returns a credential value — not to you, not to the tenant, not to another tenant. Listings return names and shape only, and your own listing of a vault leaves the tenants' namespace out. A request made as a tenant cannot list, connect or change vaults.

Whose credential a run uses

Config names a credential once, by connection name (connector_config: {connection: crm-api}, webhook_config: {connection: …}, …). At run time:

  • a run with a tenant id uses that tenant's crm-api when the tenant has pushed one;
  • otherwise it uses yours, of the same name.

Nothing extra goes in config: the same reference serves every tenant. A rotated credential is used from the next call.

Tenants' own model keys

2.4.488+. A tenant can sign its model calls with its own key, choosing from the models you allow.

  1. You list the models tenants may pick with tenant_models on your own record. You can change the list later without sending the key again.
  2. The tenant saves its key on a record with the same name and provider as yours (whatever you named it, for example openai-main), and picks one of your listed models with model. A custom record must have the same rows as yours. Studio saves it the same way.

If you later remove a model from tenant_models, a tenant that had picked it runs on the agent's own model, with a warning in the log. It never runs on a model you no longer allow.

POST /api/infra/connections
X-API-Key: <your org key>

{"name": "openai-main", "provider": "openai", "kind": "manual",
 "value": "…", "tenant_models": ["gpt-4.1"]}
POST /api/infra/connections
X-API-Key: <your org key>
X-Tenant-ID: acme-corp

{"name": "openai-main", "provider": "openai", "kind": "manual",
 "value": "…", "model": "gpt-4.1"}

GET /api/infra/tenant-credentials lists the records a tenant may fill: pasted keys only, not OAuth or API-tool records. For each it gives the name, provider, whether the tenant has saved it, and the fields, allowed models and current pick (or the rows, for a custom record). You or the tenant (with its X-Tenant-ID) may call it. It never returns a value or another tenant's data.

  • Without your key as a fallback: set tenant_credentials_only: true on an agent. It then uses only the tenant's own credentials: its model key and the record the agent's connector names. A tenant that has not connected one is stopped with this tenant has not connected <record name>, instead of running on your key.
  • The Manager always uses your key.
  • The tenant id never reaches a model. It is left out of the prompt, the request to the model provider and the payload sent to outside agents, and appears only in your own traces (leafmesh.tenant_id). The one exception: outside connectors still receive the X-Tenant-ID HTTP header.

Missing records fail closed

A block names its vault record with connection:. The one place a single value is pulled from the vault by reference is the env of an MCP stdio row (and an mcp connector's env), written ${vault:<field>}; everywhere else, use connection:.

agents: ops_desk: name: "ops_desk" agent_type: "human" human_interface: "webhook" webhook_config: connection: "ops-webhook" # the record: address, headers, inbound token

Since 2.4.481 addresses, keys and header rows are never written in the YAML: they live on the record. A connection name may still come from the environment (connection: "${SLACK_CONNECTION:slack-workspace}").

Records are resolved where the value is used, so a credential rotated on the Infra page takes effect on the next request, on every worker, without a restart.

A reference that cannot be resolved is an error, never an empty string. A mesh whose config names a missing connection still starts, but every agent that needs it is switched off: its runs fail at once with the reason, and GET /api/agents shows it under switched_off with the missing connection. Add the connection and the agent switches back on by itself.

Your own code: secret() and secret_file()

A tool, a programmatic agent or a custom connector you wrote reads a credential the same way, with the same tenant-then-yours lookup:

from leafmesh import secret, secret_file @tool(description="Look up an order") async def find_order(order_id: str) -> dict: key = await secret("crm-api") # the primary credential base = await secret("crm-api.base_url") # one named field ...

secret() raises when there is nothing to give; it never returns "". A connection may name the agents allowed to read it (agents); any other agent is refused.

File credentials

Some credentials are files — a service-account key, a client certificate and key, a kubeconfig, an SSH key, a .p12 keystore — and the tools that use them only take a path. The vault keeps the file's content, encrypted like any other secret, and secret_file() hands your tool a private path to it:

creds = await secret_file("gcp-reports.service_account_json") client = bigquery.Client.from_service_account_json(creds)

The file is readable only by the mesh's own user, each tenant gets its own file, the same version reuses the same path, a rotation replaces it, and the files are removed when the mesh stops. Binary content comes back byte-for-byte.

Cloud identity: AWS, Google Cloud and Azure

Instead of pasting keys, give the mesh one identity per cloud, as a connection that every cloud service it uses (models, vaults, brokers) takes its credentials from:

ConnectionWays to sign in
aws_identityAccess keys; assume a role (from keys or the machine's own identity, with the ExternalId your trust policy requires); a role assumed with an OIDC token (EKS, AKS workload identity, or an Azure managed identity); or the machine's own identity.
gcp_identityA service-account key; workload identity federation (trusting an AWS role, an Azure identity or any OIDC issuer); or the attached service account.
azure_identityA service principal, or a managed identity.

Temporary credentials are renewed before they expire. Name the connection where it is used — for example mesh.bedrock.connection, mesh.vertex.connection or mesh.foundry.connection.

Rotating the master key

The built-in vault is encrypted with a master key your deployment holds: LEAFMESH_VAULT_KEY in .env (2.4.507: the mesh never makes or derives one). Move every stored credential onto a new key with:

leafmesh vault rotate-key --new-key-file <file> [--generate] [--yes] [--config <file>] # e.g. leafmesh vault rotate-key --new-key-file /secure/new-vault.key --generate
OptionMeaning
--new-key-file PATHThe file holding the new key (required).
--generateCreate a new key into that file, readable only by you.
--yesRotate without asking.
--config PATHOptional. Only names the agency; the store always comes from .env.

Stop every worker first: the command refuses while anything that shares the store is running. It prints the plan before changing anything, and asks you to confirm unless you pass --yes. It never prints a key. Then start every worker with the new key (LEAFMESH_VAULT_KEY).

API

All vault routes belong to you, the mesh owner; a request made as a tenant is refused.

EndpointPurpose
GET /api/infra/vaultsThe vaults, the built-in one last; stores_new_credentials marks where new credentials go (and, from 2.4.507, which writable vault is read).
GET /api/infra/vaults/typesEvery kind of vault that can be connected, with the form to connect it.
POST /api/infra/vaultsConnect a vault: {name, type, config, credentials, settings: {writable}}. Checked before it is saved.
PUT /api/infra/vaults/activeChoose where new credentials go: {"name": "mesh"} or a connected vault's name. From 2.4.507 the old vault is no longer read; enter each credential again.
DELETE /api/infra/vaults/{name}Disconnect a vault. Its secrets stay where they are.
POST /api/infra/vaults/{name}/checkCheck that a connected vault answers and its sign-in works.
GET /api/infra/vaults/{name}/secretsWhat a vault holds for the mesh itself — names and shape, never a value, never the tenants' namespace.
GET /api/infra/tenantsThe tenants with setup on this mesh (names only). A tenant's request sees only itself.

Related

Last updated: 11/10/2026