stapel-vault 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stapel contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-vault
3
+ Version: 0.1.0
4
+ Summary: Production secret storage for the Stapel framework — OpenBao/HashiCorp Vault behind the stapel-core secret-provider seam
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/usestapel/stapel-vault
7
+ Project-URL: Repository, https://github.com/usestapel/stapel-vault
8
+ Project-URL: Documentation, https://github.com/usestapel/stapel-vault#readme
9
+ Project-URL: Changelog, https://github.com/usestapel/stapel-vault/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/usestapel/stapel-vault/issues
11
+ Keywords: django,stapel,vault,openbao,secrets,kv
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: Django
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: stapel-core<0.11,>=0.10
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.4; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
30
+ Requires-Dist: Django>=5.1; extra == "dev"
31
+ Provides-Extra: all
32
+ Dynamic: license-file
33
+
34
+ # stapel-vault
35
+
36
+ Production secret storage for the [Stapel framework](https://github.com/usestapel).
37
+ A facade over secret backends behind the `stapel_core.secrets` provider seam —
38
+ the first backend is **OpenBao / HashiCorp Vault** (KV v2; their HTTP APIs are
39
+ compatible, so one client speaks to both).
40
+
41
+ Local dev and the `minimal` preset keep reading secrets from the environment
42
+ (stapel-core's default provider). In production, where **env for secrets is
43
+ unacceptable**, point the seam at `stapel-vault` and the framework reads
44
+ `SECRET_KEY`, `JWT_SECRET_KEY`, database passwords and LLM pool keys from Vault
45
+ instead — with no change to the code that consumes them.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install stapel-vault # requires stapel-core with the SecretProvider seam
51
+ ```
52
+
53
+ ## Wire it up
54
+
55
+ The provider is selected at settings-bootstrap time (production settings
56
+ resolve `SECRET_KEY` before `django.setup()`), via environment — which is also
57
+ where Vault's own connection/auth config belongs:
58
+
59
+ ```bash
60
+ # control plane only — never a workload container (see MODULE.md, S1)
61
+ export STAPEL_SECRETS_PROVIDER=stapel_vault.VaultSecretProvider
62
+ export VAULT_ADDR=https://vault.internal:8200
63
+ export VAULT_K8S_ROLE=stapel-web # Kubernetes auth (phase 2)
64
+ ```
65
+
66
+ Then any `stapel_core.secrets.get_secret("DJANGO_SECRET_KEY")` — including the
67
+ `SECRET_KEY` / `JWT_SECRET_KEY` reads in `stapel_core.django.settings` — comes
68
+ from Vault. A missing secret is a hard, loud boot failure (`fail_closed`), not
69
+ a silent `None`.
70
+
71
+ ## Secret layout (default convention)
72
+
73
+ A service's secrets are keys of one KV v2 secret (the "bundle") at
74
+ `secret/data/<prefix>/<app>` (defaults `secret/data/stapel/app`):
75
+
76
+ ```bash
77
+ bao kv put secret/stapel/app \
78
+ DJANGO_SECRET_KEY=... JWT_SECRET_KEY=... POSTGRES_PASSWORD=...
79
+ ```
80
+
81
+ `DJANGO_SECRET_KEY` then resolves to `GET v1/secret/data/stapel/app` →
82
+ `.data.data["DJANGO_SECRET_KEY"]`. Override per name with `VAULT_SECRET_MAP`
83
+ (JSON). See [MODULE.md](MODULE.md) for the full config reference, auth methods,
84
+ rotation, and the deploy-mode map.
85
+
86
+ ## Auth methods
87
+
88
+ | Method | When | Config |
89
+ |---|---|---|
90
+ | `token` | local/dev | `VAULT_TOKEN` |
91
+ | `kubernetes` | prod on k8s (phase 2) | `VAULT_K8S_ROLE` (+ projected SA JWT) |
92
+ | `approle` | prod, non-k8s | `VAULT_ROLE_ID` + `VAULT_SECRET_ID` |
93
+
94
+ Auto-detected from what is present, or forced with `VAULT_AUTH_METHOD`.
95
+
96
+ ## License
97
+
98
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,65 @@
1
+ # stapel-vault
2
+
3
+ Production secret storage for the [Stapel framework](https://github.com/usestapel).
4
+ A facade over secret backends behind the `stapel_core.secrets` provider seam —
5
+ the first backend is **OpenBao / HashiCorp Vault** (KV v2; their HTTP APIs are
6
+ compatible, so one client speaks to both).
7
+
8
+ Local dev and the `minimal` preset keep reading secrets from the environment
9
+ (stapel-core's default provider). In production, where **env for secrets is
10
+ unacceptable**, point the seam at `stapel-vault` and the framework reads
11
+ `SECRET_KEY`, `JWT_SECRET_KEY`, database passwords and LLM pool keys from Vault
12
+ instead — with no change to the code that consumes them.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install stapel-vault # requires stapel-core with the SecretProvider seam
18
+ ```
19
+
20
+ ## Wire it up
21
+
22
+ The provider is selected at settings-bootstrap time (production settings
23
+ resolve `SECRET_KEY` before `django.setup()`), via environment — which is also
24
+ where Vault's own connection/auth config belongs:
25
+
26
+ ```bash
27
+ # control plane only — never a workload container (see MODULE.md, S1)
28
+ export STAPEL_SECRETS_PROVIDER=stapel_vault.VaultSecretProvider
29
+ export VAULT_ADDR=https://vault.internal:8200
30
+ export VAULT_K8S_ROLE=stapel-web # Kubernetes auth (phase 2)
31
+ ```
32
+
33
+ Then any `stapel_core.secrets.get_secret("DJANGO_SECRET_KEY")` — including the
34
+ `SECRET_KEY` / `JWT_SECRET_KEY` reads in `stapel_core.django.settings` — comes
35
+ from Vault. A missing secret is a hard, loud boot failure (`fail_closed`), not
36
+ a silent `None`.
37
+
38
+ ## Secret layout (default convention)
39
+
40
+ A service's secrets are keys of one KV v2 secret (the "bundle") at
41
+ `secret/data/<prefix>/<app>` (defaults `secret/data/stapel/app`):
42
+
43
+ ```bash
44
+ bao kv put secret/stapel/app \
45
+ DJANGO_SECRET_KEY=... JWT_SECRET_KEY=... POSTGRES_PASSWORD=...
46
+ ```
47
+
48
+ `DJANGO_SECRET_KEY` then resolves to `GET v1/secret/data/stapel/app` →
49
+ `.data.data["DJANGO_SECRET_KEY"]`. Override per name with `VAULT_SECRET_MAP`
50
+ (JSON). See [MODULE.md](MODULE.md) for the full config reference, auth methods,
51
+ rotation, and the deploy-mode map.
52
+
53
+ ## Auth methods
54
+
55
+ | Method | When | Config |
56
+ |---|---|---|
57
+ | `token` | local/dev | `VAULT_TOKEN` |
58
+ | `kubernetes` | prod on k8s (phase 2) | `VAULT_K8S_ROLE` (+ projected SA JWT) |
59
+ | `approle` | prod, non-k8s | `VAULT_ROLE_ID` + `VAULT_SECRET_ID` |
60
+
61
+ Auto-detected from what is present, or forced with `VAULT_AUTH_METHOD`.
62
+
63
+ ## License
64
+
65
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,61 @@
1
+ """stapel-vault — production secret storage for the Stapel framework.
2
+
3
+ A facade over secret backends behind the ``stapel_core.secrets`` provider seam.
4
+ The first backend is **OpenBao / HashiCorp Vault** (KV v2 — their HTTP APIs are
5
+ compatible, so one client speaks to both). Point the core seam at it and the
6
+ framework reads ``SECRET_KEY`` / ``JWT_SECRET_KEY`` / DB passwords / LLM pool
7
+ keys from Vault instead of the environment — the decision that "env for prod
8
+ secrets is unacceptable" (arch-stapel-vault).
9
+
10
+ # deployment environment (control plane only — S1: never a workload container)
11
+ export STAPEL_SECRETS_PROVIDER=stapel_vault.VaultSecretProvider
12
+ export VAULT_ADDR=https://vault.internal:8200
13
+ export VAULT_K8S_ROLE=stapel-web
14
+
15
+ See MODULE.md for the deploy-mode map (local=env / prod=vault+k8s auth) and
16
+ the S1 constraint that workload containers never see Vault.
17
+
18
+ Public API is lazily exported (PEP 562) so importing this package never runs
19
+ the provider's relative imports until an attribute is actually used.
20
+ """
21
+
22
+ try: # single source of truth: pyproject version via package metadata
23
+ from importlib.metadata import version as _pkg_version
24
+
25
+ __version__ = _pkg_version("stapel-vault")
26
+ except Exception: # editable/vendored checkout without dist-info
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "SecretProvider",
31
+ "VaultAuthError",
32
+ "VaultConfigError",
33
+ "VaultError",
34
+ "VaultSecretProvider",
35
+ "VaultTransportError",
36
+ "__version__",
37
+ ]
38
+
39
+ # name -> submodule that defines it. Deferred until first attribute access.
40
+ _LAZY_EXPORTS = {
41
+ "SecretProvider": ".provider",
42
+ "VaultSecretProvider": ".provider",
43
+ "VaultAuthError": ".exceptions",
44
+ "VaultConfigError": ".exceptions",
45
+ "VaultError": ".exceptions",
46
+ "VaultTransportError": ".exceptions",
47
+ }
48
+
49
+
50
+ def __getattr__(name):
51
+ if name in _LAZY_EXPORTS:
52
+ from importlib import import_module
53
+
54
+ value = getattr(import_module(_LAZY_EXPORTS[name], __name__), name)
55
+ globals()[name] = value # cache for subsequent lookups
56
+ return value
57
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
58
+
59
+
60
+ def __dir__():
61
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,117 @@
1
+ """Vault auth methods: token (dev), kubernetes (service-account JWT), approle.
2
+
3
+ Every method produces a short-lived *client token*; :class:`Authenticator`
4
+ caches it until shortly before its lease expires and re-authenticates on
5
+ demand (the token/lease TTL is honored — the same re-read-by-TTL idea the
6
+ core seam applies to secret values). ``invalidate()`` drops the cached token
7
+ so a 403 (token expired/revoked mid-flight) triggers exactly one re-auth.
8
+
9
+ Kubernetes auth (deploy-topology phase 2): the pod's projected service-account
10
+ JWT at ``VAULT_K8S_JWT_PATH`` is exchanged at
11
+ ``auth/<mount>/login`` for a Vault token bound to a Vault role. approle
12
+ (``role_id`` + ``secret_id``) is the non-k8s server option; token auth is for
13
+ local/dev.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ import time
19
+
20
+ from .client import VaultHTTPClient
21
+ from .config import VaultConfig
22
+ from .exceptions import VaultAuthError, VaultConfigError
23
+
24
+ # Re-authenticate once the token has less than this fraction of its lease left.
25
+ _RENEW_AT = 0.9
26
+ # Floor lease used when Vault reports a non-renewable/zero lease (token auth).
27
+ _MIN_LEASE = 60.0
28
+
29
+
30
+ class Authenticator:
31
+ """Produces (and refreshes) a valid Vault client token for the configured method."""
32
+
33
+ def __init__(self, config: VaultConfig, client: VaultHTTPClient) -> None:
34
+ self.config = config
35
+ self.client = client
36
+ self._lock = threading.Lock()
37
+ self._token: str | None = None
38
+ self._expires_at: float = 0.0
39
+
40
+ def token(self) -> str:
41
+ """A currently-valid client token (login/renew as needed)."""
42
+ now = time.monotonic()
43
+ tok = self._token
44
+ if tok is not None and self._expires_at > now:
45
+ return tok
46
+ with self._lock:
47
+ if self._token is not None and self._expires_at > time.monotonic():
48
+ return self._token
49
+ tok, lease = self._authenticate()
50
+ self._token = tok
51
+ self._expires_at = time.monotonic() + max(_MIN_LEASE, lease) * _RENEW_AT
52
+ return tok
53
+
54
+ def invalidate(self) -> None:
55
+ """Forget the cached token so the next :meth:`token` re-authenticates."""
56
+ with self._lock:
57
+ self._token = None
58
+ self._expires_at = 0.0
59
+
60
+ # -- per-method login ---------------------------------------------------
61
+
62
+ def _authenticate(self) -> tuple[str, float]:
63
+ method = self.config.auth_method
64
+ if method == "token":
65
+ if not self.config.token:
66
+ raise VaultConfigError(
67
+ "auth method 'token' requires VAULT_TOKEN (or STAPEL_VAULT['TOKEN'])"
68
+ )
69
+ # A directly-supplied token has no lease we manage; treat as static.
70
+ return self.config.token, float("inf")
71
+ if method == "kubernetes":
72
+ return self._login_kubernetes()
73
+ if method == "approle":
74
+ return self._login_approle()
75
+ raise VaultConfigError(f"unknown auth method {method!r}") # pragma: no cover
76
+
77
+ def _login_kubernetes(self) -> tuple[str, float]:
78
+ if not self.config.k8s_role:
79
+ raise VaultConfigError(
80
+ "auth method 'kubernetes' requires VAULT_K8S_ROLE"
81
+ )
82
+ try:
83
+ with open(self.config.k8s_jwt_path, encoding="utf-8") as fh:
84
+ jwt = fh.read().strip()
85
+ except OSError as exc:
86
+ raise VaultConfigError(
87
+ f"cannot read Kubernetes service-account JWT at "
88
+ f"{self.config.k8s_jwt_path}: {exc}"
89
+ ) from exc
90
+ return self._login({"role": self.config.k8s_role, "jwt": jwt})
91
+
92
+ def _login_approle(self) -> tuple[str, float]:
93
+ if not (self.config.role_id and self.config.secret_id):
94
+ raise VaultConfigError(
95
+ "auth method 'approle' requires VAULT_ROLE_ID and VAULT_SECRET_ID"
96
+ )
97
+ return self._login(
98
+ {"role_id": self.config.role_id, "secret_id": self.config.secret_id}
99
+ )
100
+
101
+ def _login(self, payload: dict) -> tuple[str, float]:
102
+ path = f"v1/auth/{self.config.auth_mount}/login"
103
+ resp = self.client.request("POST", path, json_body=payload)
104
+ if resp.status != 200:
105
+ errors = resp.data.get("errors") or [f"HTTP {resp.status}"]
106
+ raise VaultAuthError(
107
+ f"{self.config.auth_method} login to {path} failed: {errors}"
108
+ )
109
+ auth = resp.data.get("auth") or {}
110
+ client_token = auth.get("client_token")
111
+ if not client_token:
112
+ raise VaultAuthError(f"{path} returned no client_token")
113
+ lease = float(auth.get("lease_duration") or 0) or _MIN_LEASE
114
+ return client_token, lease
115
+
116
+
117
+ __all__ = ["Authenticator"]
@@ -0,0 +1,92 @@
1
+ """Minimal Vault / OpenBao HTTP client (stdlib ``urllib`` — no heavy deps).
2
+
3
+ Design decision (arch-stapel-vault Part 2): we do **not** depend on ``hvac``.
4
+ It is a large dependency for what we need (a couple of JSON endpoints), it
5
+ pulls its own ``requests`` stack, and this facade must be importable at
6
+ settings-bootstrap time in a control-plane process where a slim dependency
7
+ footprint matters. The OpenBao and HashiCorp Vault HTTP APIs are compatible,
8
+ so one small ``urllib`` client speaks to both. ``requests`` is avoided too —
9
+ ``urllib`` is stdlib and always present, so the provider adds *zero* runtime
10
+ dependencies of its own.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import urllib.error
16
+ import urllib.request
17
+ from dataclasses import dataclass
18
+
19
+ from .exceptions import VaultTransportError
20
+
21
+
22
+ @dataclass
23
+ class VaultResponse:
24
+ status: int
25
+ data: dict
26
+
27
+
28
+ class VaultHTTPClient:
29
+ """Tiny JSON-over-HTTP client for the Vault/OpenBao API.
30
+
31
+ Only :meth:`request` touches the network — tests mock this one method.
32
+ """
33
+
34
+ def __init__(self, addr: str, namespace: str | None = None, timeout: float = 5.0) -> None:
35
+ self.addr = addr.rstrip("/")
36
+ self.namespace = namespace
37
+ self.timeout = timeout
38
+
39
+ def request(
40
+ self,
41
+ method: str,
42
+ path: str,
43
+ *,
44
+ token: str | None = None,
45
+ json_body: dict | None = None,
46
+ ) -> VaultResponse:
47
+ """Perform one request. Returns status + parsed JSON.
48
+
49
+ Raises :class:`VaultTransportError` for unreachable host, timeout, a
50
+ 5xx, or an unparseable body. Application-level statuses (200, 403,
51
+ 404, …) are returned for the caller to interpret.
52
+ """
53
+ url = f"{self.addr}/{path.lstrip('/')}"
54
+ body = json.dumps(json_body).encode() if json_body is not None else None
55
+ headers = {"Accept": "application/json"}
56
+ if body is not None:
57
+ headers["Content-Type"] = "application/json"
58
+ if token:
59
+ headers["X-Vault-Token"] = token
60
+ if self.namespace:
61
+ headers["X-Vault-Namespace"] = self.namespace
62
+
63
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
64
+ try:
65
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310
66
+ return VaultResponse(resp.status, _parse(resp.read()))
67
+ except urllib.error.HTTPError as exc:
68
+ payload = _parse(exc.read() or b"")
69
+ if exc.code >= 500:
70
+ raise VaultTransportError(
71
+ f"Vault returned {exc.code} for {method} {path}: "
72
+ f"{payload.get('errors') or exc.reason}",
73
+ status=exc.code,
74
+ ) from exc
75
+ return VaultResponse(exc.code, payload)
76
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
77
+ raise VaultTransportError(
78
+ f"cannot reach Vault at {self.addr} ({type(exc).__name__}: {exc})"
79
+ ) from exc
80
+
81
+
82
+ def _parse(raw: bytes) -> dict:
83
+ if not raw:
84
+ return {}
85
+ try:
86
+ value = json.loads(raw)
87
+ except ValueError as exc:
88
+ raise VaultTransportError(f"Vault response was not JSON: {exc}") from exc
89
+ return value if isinstance(value, dict) else {"data": value}
90
+
91
+
92
+ __all__ = ["VaultHTTPClient", "VaultResponse"]
@@ -0,0 +1,146 @@
1
+ """Bootstrap-tolerant configuration for the Vault facade.
2
+
3
+ Vault connection config (address, auth, mount) legitimately lives in the
4
+ **environment / Kubernetes**, not in Django settings — because a production
5
+ settings module resolves ``SECRET_KEY`` through Vault *before* ``django.setup()``
6
+ runs. So every key is resolved env-first, with an optional ``STAPEL_VAULT``
7
+ Django-settings override that only applies once Django is configured.
8
+
9
+ Resolution order per key: ``STAPEL_VAULT[<key>]`` (when Django is configured)
10
+ → environment variable (the ``VAULT_*`` name) → default.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass
16
+
17
+ from .exceptions import VaultConfigError
18
+
19
+ # Django-settings namespace for optional overrides (post-setup only).
20
+ SETTINGS_NAMESPACE = "STAPEL_VAULT"
21
+
22
+ # Default location of the Kubernetes service-account JWT (projected token).
23
+ DEFAULT_K8S_JWT_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token" # noqa: S105
24
+
25
+
26
+ def _setting(key: str) -> str | None:
27
+ """Value from ``STAPEL_VAULT[key]`` if Django is configured, else None."""
28
+ try:
29
+ from django.conf import settings
30
+
31
+ ns = getattr(settings, SETTINGS_NAMESPACE, None) or {}
32
+ val = ns.get(key)
33
+ return None if val is None else str(val)
34
+ except Exception:
35
+ # Django not configured (bootstrap) — env is the source of truth.
36
+ return None
37
+
38
+
39
+ def _resolve(key: str, env: str, default: str | None = None) -> str | None:
40
+ val = _setting(key)
41
+ if val is not None:
42
+ return val
43
+ val = os.environ.get(env)
44
+ if val is not None and val != "":
45
+ return val
46
+ return default
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class VaultConfig:
51
+ """Resolved connection + mapping configuration for :class:`VaultSecretProvider`."""
52
+
53
+ addr: str
54
+ namespace: str | None
55
+ kv_mount: str
56
+ path_prefix: str
57
+ app: str
58
+ kv_version: int | None
59
+ timeout: float
60
+ bundle_cache_ttl: float
61
+ # auth
62
+ auth_method: str # "token" | "kubernetes" | "approle"
63
+ token: str | None
64
+ auth_mount: str
65
+ k8s_role: str | None
66
+ k8s_jwt_path: str
67
+ role_id: str | None
68
+ secret_id: str | None
69
+ # optional explicit per-name mapping: {"NAME": "path#key", ...}
70
+ secret_map: dict[str, str]
71
+
72
+
73
+ def _auto_auth_method() -> str:
74
+ """Pick an auth method from what is present in the environment/settings."""
75
+ if _resolve("TOKEN", "VAULT_TOKEN"):
76
+ return "token"
77
+ if _resolve("ROLE_ID", "VAULT_ROLE_ID"):
78
+ return "approle"
79
+ if _resolve("K8S_ROLE", "VAULT_K8S_ROLE") or os.path.exists(
80
+ _resolve("K8S_JWT_PATH", "VAULT_K8S_JWT_PATH", DEFAULT_K8S_JWT_PATH) or ""
81
+ ):
82
+ return "kubernetes"
83
+ return "token"
84
+
85
+
86
+ def _default_auth_mount(method: str) -> str:
87
+ return {"kubernetes": "kubernetes", "approle": "approle", "token": "token"}[method]
88
+
89
+
90
+ def load_config(**overrides) -> VaultConfig:
91
+ """Resolve a :class:`VaultConfig`. Explicit *overrides* win over everything.
92
+
93
+ Raises :class:`VaultConfigError` for an unknown auth method (missing
94
+ required credentials are surfaced lazily at auth time so a misconfigured
95
+ non-token method still constructs — the boot error then names Vault).
96
+ """
97
+ import json
98
+
99
+ def ov(name, resolver):
100
+ return overrides[name] if name in overrides else resolver()
101
+
102
+ auth_method = ov("auth_method", lambda: _resolve("AUTH_METHOD", "VAULT_AUTH_METHOD") or _auto_auth_method())
103
+ if auth_method not in ("token", "kubernetes", "approle"):
104
+ raise VaultConfigError(
105
+ f"unknown VAULT auth method {auth_method!r} (expected token, "
106
+ "kubernetes or approle)"
107
+ )
108
+
109
+ raw_map = ov("secret_map", lambda: _resolve("SECRET_MAP", "VAULT_SECRET_MAP"))
110
+ if isinstance(raw_map, str):
111
+ try:
112
+ secret_map = dict(json.loads(raw_map))
113
+ except (ValueError, TypeError) as exc:
114
+ raise VaultConfigError(f"VAULT_SECRET_MAP is not valid JSON: {exc}") from exc
115
+ else:
116
+ secret_map = dict(raw_map or {})
117
+
118
+ def _int(name, env):
119
+ v = _resolve(name, env)
120
+ return int(v) if v not in (None, "") else None
121
+
122
+ def _float(name, env, default):
123
+ v = _resolve(name, env)
124
+ return float(v) if v not in (None, "") else default
125
+
126
+ return VaultConfig(
127
+ addr=ov("addr", lambda: _resolve("ADDR", "VAULT_ADDR", "http://127.0.0.1:8200")).rstrip("/"),
128
+ namespace=ov("namespace", lambda: _resolve("NAMESPACE", "VAULT_NAMESPACE")),
129
+ kv_mount=ov("kv_mount", lambda: _resolve("KV_MOUNT", "VAULT_KV_MOUNT", "secret")),
130
+ path_prefix=ov("path_prefix", lambda: _resolve("SECRET_PATH_PREFIX", "VAULT_SECRET_PATH_PREFIX", "stapel")),
131
+ app=ov("app", lambda: _resolve("SECRET_APP", "VAULT_SECRET_APP", "app")),
132
+ kv_version=ov("kv_version", lambda: _int("KV_VERSION", "VAULT_KV_VERSION")),
133
+ timeout=ov("timeout", lambda: _float("HTTP_TIMEOUT", "VAULT_HTTP_TIMEOUT", 5.0)),
134
+ bundle_cache_ttl=ov("bundle_cache_ttl", lambda: _float("BUNDLE_CACHE_TTL", "VAULT_BUNDLE_CACHE_TTL", 0.0)),
135
+ auth_method=auth_method,
136
+ token=ov("token", lambda: _resolve("TOKEN", "VAULT_TOKEN")),
137
+ auth_mount=ov("auth_mount", lambda: _resolve("AUTH_MOUNT", "VAULT_AUTH_MOUNT") or _default_auth_mount(auth_method)),
138
+ k8s_role=ov("k8s_role", lambda: _resolve("K8S_ROLE", "VAULT_K8S_ROLE")),
139
+ k8s_jwt_path=ov("k8s_jwt_path", lambda: _resolve("K8S_JWT_PATH", "VAULT_K8S_JWT_PATH", DEFAULT_K8S_JWT_PATH)),
140
+ role_id=ov("role_id", lambda: _resolve("ROLE_ID", "VAULT_ROLE_ID")),
141
+ secret_id=ov("secret_id", lambda: _resolve("SECRET_ID", "VAULT_SECRET_ID")),
142
+ secret_map=secret_map,
143
+ )
144
+
145
+
146
+ __all__ = ["DEFAULT_K8S_JWT_PATH", "SETTINGS_NAMESPACE", "VaultConfig", "load_config"]
@@ -0,0 +1,21 @@
1
+ """Pytest bootstrap.
2
+
3
+ Config resolution is env-first and works with no Django at all; Django is
4
+ configured here only so the ``STAPEL_VAULT`` settings-override path (and
5
+ ``override_settings``) can be exercised. No apps, no database.
6
+ """
7
+
8
+
9
+ def pytest_configure(config):
10
+ from django.conf import settings
11
+
12
+ if not settings.configured:
13
+ settings.configure(
14
+ DEBUG=False,
15
+ INSTALLED_APPS=[],
16
+ DATABASES={},
17
+ USE_TZ=True,
18
+ )
19
+ import django
20
+
21
+ django.setup()
@@ -0,0 +1,39 @@
1
+ """Exceptions raised inside the Vault facade.
2
+
3
+ These are *internal* failures (misconfiguration, auth failure, transport
4
+ error). They are deliberately distinct from ``stapel_core.secrets``'s
5
+ ``SecretUnavailable`` — that one means "the secret simply isn't there and no
6
+ default was given", which the core seam raises after a provider returns
7
+ ``None``. A ``VaultError`` here means "I could not even ask Vault properly",
8
+ which propagates fail-closed through ``get_secret`` (a boot-stopping error, as
9
+ intended for a production secret store).
10
+ """
11
+ from __future__ import annotations
12
+
13
+
14
+ class VaultError(Exception):
15
+ """Base class for all stapel-vault failures."""
16
+
17
+
18
+ class VaultConfigError(VaultError):
19
+ """The provider is misconfigured (missing address, unknown auth method…)."""
20
+
21
+
22
+ class VaultAuthError(VaultError):
23
+ """Authentication to Vault/OpenBao failed (bad token, role, secret-id…)."""
24
+
25
+
26
+ class VaultTransportError(VaultError):
27
+ """A network/HTTP-level failure talking to Vault (timeout, 5xx, unreachable)."""
28
+
29
+ def __init__(self, message: str, *, status: int | None = None) -> None:
30
+ self.status = status
31
+ super().__init__(message)
32
+
33
+
34
+ __all__ = [
35
+ "VaultAuthError",
36
+ "VaultConfigError",
37
+ "VaultError",
38
+ "VaultTransportError",
39
+ ]