Skip to content

nano-interactive/vault-provider-py

Repository files navigation

vault-provider-py

License

vault-provider-py icon

A small Python library that fetches HashiCorp Vault credentials at startup and injects them into your application config by reference. It supports running in Kubernetes (JWT login) and locally (OIDC SSO in the browser), with environment detected automatically.

Features

  • Config injection: Pass a mutable config (dataclass instance, dict, nested list/dict, or Dynaconf Dynaconf(...) / LazySettings); any string value exactly equal to vault:path#key is replaced with the secret from Vault (in place). Secret values may be JSON-typed in KV (numbers, booleans, lists, objects, null), not only strings.
  • Lazy init: The library does not contact Vault until at least one vault:path#key placeholder is present. If there are no placeholders, inject_secrets returns immediately and provider config is unused—no enable flag needed; you can always call new + inject_secrets.
  • Optional config: All provider settings have defaults; you can pass None or only override what you need.
  • Dual run mode: In-cluster uses Kubernetes auth; on a developer machine uses OIDC (browser). No extra configuration required.

Installation

pip install vault-provider-py
# optional file loaders for examples in settings.example.* :
# pip install "vault-provider-py[yaml]"   # PyYAML
# pip install "vault-provider-py[toml]"   # tomli on Python 3.10 (3.11+ has stdlib tomllib)
# pip install "vault-provider-py[dynaconf]"  # if you use Dynaconf (often redundant; app already depends on it)

Requires Python 3.10+, hvac, and httpx (OIDC callback / auth URL requests).

Quick start

from dataclasses import dataclass

from vault_provider import new


@dataclass
class AppConfig:
    api_key: str
    db_conn_str: str


def main() -> None:
    # None uses all defaults (see Defaults below).
    vp = new(None)

    cfg = AppConfig(
        api_key="vault:app/data/myapp/credentials#api_key",
        db_conn_str="vault:app/data/myapp/credentials#database_url",
    )

    vp.inject_secrets(cfg)

    # cfg.api_key and cfg.db_conn_str now hold the real secret values.

Placeholder format

Any string field (including inside nested dataclasses, lists, or dicts) whose value is exactly:

vault:<path>#<key>

is replaced with the secret at that Vault path and key. The library walks your config and only mutates strings that match this pattern.

  • path: Vault KV path (e.g. app/data/myapp/credentials).
  • key: Key name at that path (e.g. api_key).

Both KV v1 and KV v2 engines are supported (v2 nesting under data is handled automatically).

Secret value types (JSON / Vault UI)

The value stored in Vault for key may be any JSON type (as in Vault’s JSON editor): strings, numbers, booleans, null, arrays, and objects (with string keys). After injection, your config field holds the corresponding Python value (str, int, float, bool, None, list, dict). Values that are not JSON-like (e.g. unexpected custom objects) are rejected with TypeError.

If a value is stored as a JSON string (e.g. the string "42"), it remains a Python str; the library does not coerce it to a number.

Dynaconf

You can pass the object returned by Dynaconf(...) directly to inject_secrets:

from dynaconf import Dynaconf
from vault_provider import new

settings = Dynaconf(settings_files=["settings.toml"])
vp = new(None)
vp.inject_secrets(settings)
  • Top-level keys are taken from settings.as_dict(internal=False) so Dynaconf’s own defaults (*_FOR_DYNACONF, etc.) are not scanned.
  • Top-level string placeholders are updated with settings.set(key, value).
  • Nested DynaBox objects behave like dict and are updated in place.
  • Lists (including Dynaconf’s BoxList) are copied to a plain list, injected, then written back (settings.set at the top level, or reassignment on the parent DynaBox) because BoxList[i] = ... does not persist into the settings store.

Dynaconf is an optional dependency: it is loaded only if installed. Use pip install "vault-provider-py[dynaconf]" if you want that extra pinned alongside this library.

Avoid setting top-level keys whose names shadow dict APIs on DynaBox (e.g. items, keys)—prefer settings["ITEMS"] / a different key name, or use bracket access when reading.

Lazy initialization

new(cfg) only applies defaults and never contacts Vault. The client is created and auth runs on the first inject_secrets call that finds at least one placeholder. If your config has no placeholders, inject_secrets returns without touching Vault, so provider config does not matter in that case. Auth errors (e.g. unreachable Vault, bad credentials) are raised from inject_secrets, not from new.

Optional configuration

You can pass a partial or full config. Empty fields are filled from defaults.

from vault_provider import Config, new

# Use defaults for everything.
vp = new(None)

# Override only Vault address and role.
vp = new(Config(vault_addr="https://vault.example.com", role_name="my-role"))

# Load from your own config file (e.g. YAML with PyYAML).
import yaml

with open("provider.yml") as f:
    raw = yaml.safe_load(f)
provider_cfg = Config(
    vault_addr=raw.get("vault_addr") or "",
    role_name=raw.get("role_name") or "",
    auth_path=raw.get("auth_path") or "",
)
vp = new(provider_cfg)

# Or TOML (stdlib tomllib on Python 3.11+; on 3.10 use: pip install tomli, then import tomli as tomllib)
try:
    import tomllib
except ModuleNotFoundError:  # Python < 3.11
    import tomli as tomllib

with open("provider.toml", "rb") as f:
    raw = tomllib.load(f)
provider_cfg = Config(
    vault_addr=raw.get("vault_addr") or "",
    role_name=raw.get("role_name") or "",
    auth_path=raw.get("auth_path") or "",
)
vp = new(provider_cfg)

See settings.example.yml and settings.example.toml for sample layouts (same keys in both formats).

Config fields

Field YAML / TOML key Description
vault_addr vault_addr Vault server URL.
role_name role_name Auth role name (e.g. Kubernetes role or OIDC).
auth_path auth_path Auth method mount path (see Run modes).

Defaults

When a field is not set (or config is None), the library uses:

Field Default
vault_addr http://127.0.0.1:8200
role_name default
auth_path Local: oidcKubernetes: kubernetes (chosen automatically)

No environment variables are used; only the config (or these defaults) drive behaviour.

Run modes

  • Kubernetes: If the process runs inside a pod (detected via /var/run/secrets/kubernetes.io/serviceaccount/token), the library uses Kubernetes auth (JWT from the service account). No browser; no local token file.
  • Local: Otherwise it uses OIDC auth: it opens a browser for SSO, then stores the token in ~/.vault-token and reuses it on the next run.

Same code and config work in both environments; override auth_path only if your Vault uses a non-default mount (e.g. oidc-dev or kubernetes-staging).

Project layout

.
├── src/vault_provider/
│   ├── config.py          # Config, defaults, apply_defaults
│   ├── client.py          # new(), VaultProvider, lazy ensure_client, read_secret_at
│   ├── inject.py          # inject_secrets walk, placeholder parsing
│   ├── auth_local.py      # OIDC flow (browser, ~/.vault-token)
│   └── auth_k8s.py        # Kubernetes JWT login
├── tests/
│   ├── test_client.py      # Unit tests (mock Vault, defaults, inject)
│   ├── test_dynaconf.py    # Dynaconf LazySettings injection
│   └── test_integration.py # Real Vault (RUN_VAULT_INTEGRATION=1)
├── settings.example.yml
├── settings.example.toml
├── pyproject.toml
├── LICENSE
└── README.md

Contributing

  1. Code style: Use Ruff (ruff check src tests) or your formatter of choice; keep the public API small: Config, new, VaultProvider.inject_secrets, apply_defaults.
  2. Tests: Run unit tests with pytest tests/test_client.py tests/test_dynaconf.py. Mocks use pytest-httpserver so CI does not need Vault.
  3. Integration tests: Set RUN_VAULT_INTEGRATION=1 and run pytest tests/test_integration.py -v when changing auth or injection.
  4. Defaults: Changing defaults affects all users; document any change in the README and consider backwards compatibility.
  5. Auth flow: Do not alter the existing auth branching in client.py (is_kubernetes()authenticate_kubernetes / authenticate_local) without updating this README and the run modes section.

License

This project is licensed under the Apache License 2.0. See LICENSE for the full text.

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages