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.
- stapel_vault-0.1.0/LICENSE +21 -0
- stapel_vault-0.1.0/PKG-INFO +98 -0
- stapel_vault-0.1.0/README.md +65 -0
- stapel_vault-0.1.0/__init__.py +61 -0
- stapel_vault-0.1.0/auth.py +117 -0
- stapel_vault-0.1.0/client.py +92 -0
- stapel_vault-0.1.0/config.py +146 -0
- stapel_vault-0.1.0/conftest.py +21 -0
- stapel_vault-0.1.0/exceptions.py +39 -0
- stapel_vault-0.1.0/mapping.py +74 -0
- stapel_vault-0.1.0/provider.py +130 -0
- stapel_vault-0.1.0/py.typed +0 -0
- stapel_vault-0.1.0/pyproject.toml +79 -0
- stapel_vault-0.1.0/setup.cfg +4 -0
- stapel_vault-0.1.0/stapel_vault.egg-info/PKG-INFO +98 -0
- stapel_vault-0.1.0/stapel_vault.egg-info/SOURCES.txt +31 -0
- stapel_vault-0.1.0/stapel_vault.egg-info/dependency_links.txt +1 -0
- stapel_vault-0.1.0/stapel_vault.egg-info/requires.txt +8 -0
- stapel_vault-0.1.0/stapel_vault.egg-info/top_level.txt +1 -0
- stapel_vault-0.1.0/tests/test_auth.py +98 -0
- stapel_vault-0.1.0/tests/test_client.py +98 -0
- stapel_vault-0.1.0/tests/test_config_mapping.py +90 -0
- stapel_vault-0.1.0/tests/test_integration.py +43 -0
- stapel_vault-0.1.0/tests/test_provider.py +145 -0
|
@@ -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
|
+
]
|