dotvault 0.24.0__py3-none-win_amd64.whl
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.
- dotvault/__init__.py +256 -0
- dotvault/_dotvault.dll +0 -0
- dotvault/_errors.py +74 -0
- dotvault/_ffi.py +87 -0
- dotvault/py.typed +0 -0
- dotvault-0.24.0.dist-info/METADATA +133 -0
- dotvault-0.24.0.dist-info/RECORD +9 -0
- dotvault-0.24.0.dist-info/WHEEL +5 -0
- dotvault-0.24.0.dist-info/top_level.txt +1 -0
dotvault/__init__.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""dotvault — Python bindings for the dotvault public client API.
|
|
2
|
+
|
|
3
|
+
This package lets a Python program read the per-user secrets a dotvault daemon
|
|
4
|
+
enrolled and keeps current, talking to the same Vault, resolving a token the
|
|
5
|
+
same way, and reading from the exact ``kv/users/<user>/...`` path dotvault
|
|
6
|
+
writes to. It is a thin ctypes wrapper over dotvault's Go client compiled to a
|
|
7
|
+
shared library, so the connectivity, token precedence, and identity convention
|
|
8
|
+
all come from the one canonical Go implementation rather than being re-derived
|
|
9
|
+
in Python.
|
|
10
|
+
|
|
11
|
+
The surface is the read-only + cached-auth subset of the Go facade:
|
|
12
|
+
|
|
13
|
+
import dotvault
|
|
14
|
+
|
|
15
|
+
with dotvault.Client() as c: # default config path, OS-user identity
|
|
16
|
+
c.authenticate_cached(timeout=5) # env -> token file; never prompts
|
|
17
|
+
token = c.read_user_secret("gh", "oauth_token")
|
|
18
|
+
|
|
19
|
+
Authentication never prompts: it resolves ``DOTVAULT_TOKEN`` then the token
|
|
20
|
+
file and validates it. If no usable token is present it raises ``LoginRequired``
|
|
21
|
+
— provision one with ``dotvault login`` (or let the daemon do it). Interactive
|
|
22
|
+
browser/terminal login is deliberately out of scope for these bindings.
|
|
23
|
+
|
|
24
|
+
Identity is the OS user, not the Vault token: by default the ``<user>`` path
|
|
25
|
+
segment is the OS account the process runs as. A process running as a different
|
|
26
|
+
user (a service, a container) must pass ``identity=`` to read another user's
|
|
27
|
+
secrets. See the package README for the full rationale.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from ctypes import byref, c_int, c_longlong, c_void_p
|
|
33
|
+
|
|
34
|
+
from . import _ffi
|
|
35
|
+
from ._errors import (
|
|
36
|
+
CAT_OK,
|
|
37
|
+
AuthFailed,
|
|
38
|
+
Denied,
|
|
39
|
+
DotvaultError,
|
|
40
|
+
LoginRequired,
|
|
41
|
+
Unreachable,
|
|
42
|
+
error_for,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Client",
|
|
47
|
+
"default_config_path",
|
|
48
|
+
"DotvaultError",
|
|
49
|
+
"LoginRequired",
|
|
50
|
+
"AuthFailed",
|
|
51
|
+
"Denied",
|
|
52
|
+
"Unreachable",
|
|
53
|
+
"__version__",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# Resolve the installed distribution's version (baked in at build time by
|
|
57
|
+
# setuptools-scm from the repo's git tags). Running from an uninstalled source
|
|
58
|
+
# tree — e.g. the test suite, which adds src/ to sys.path without installing —
|
|
59
|
+
# has no metadata, so fall back to a sentinel rather than failing import.
|
|
60
|
+
try:
|
|
61
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
62
|
+
|
|
63
|
+
__version__ = _pkg_version("dotvault")
|
|
64
|
+
except PackageNotFoundError:
|
|
65
|
+
__version__ = "0.0.0+unknown"
|
|
66
|
+
|
|
67
|
+
# Sentinel handle for a closed/never-opened client. The bridge never issues 0.
|
|
68
|
+
_CLOSED = 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def default_config_path() -> str:
|
|
72
|
+
"""Return dotvault's platform default system-config path.
|
|
73
|
+
|
|
74
|
+
The same file the daemon loads — ``/etc/xdg/dotvault/config.yaml`` on Linux
|
|
75
|
+
(honouring ``XDG_CONFIG_DIRS``), ``%ProgramData%\\dotvault\\config.yaml`` on
|
|
76
|
+
Windows (or the GPO registry when policy keys are present), Application
|
|
77
|
+
Support on macOS.
|
|
78
|
+
"""
|
|
79
|
+
return _ffi.take_str(_ffi.lib.dotvault_default_config_path()) or ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _millis(timeout: float | None) -> int:
|
|
83
|
+
"""Convert a seconds timeout to whole milliseconds.
|
|
84
|
+
|
|
85
|
+
``None`` means no deadline (-> 0). A positive ``timeout`` is truncated to
|
|
86
|
+
whole milliseconds; note that a sub-millisecond or non-positive value also
|
|
87
|
+
yields 0, which the bridge treats as "no deadline" — so a 0.0004s timeout
|
|
88
|
+
is effectively unbounded. Pass at least 0.001 for a real deadline.
|
|
89
|
+
"""
|
|
90
|
+
if timeout is None:
|
|
91
|
+
return 0
|
|
92
|
+
ms = int(timeout * 1000)
|
|
93
|
+
return ms if ms > 0 else 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _check(code: int, err_ptr: c_void_p) -> None:
|
|
97
|
+
"""Raise the mapped exception for a non-OK category, consuming the message."""
|
|
98
|
+
message = _ffi.take_str(err_ptr)
|
|
99
|
+
if code != CAT_OK:
|
|
100
|
+
raise error_for(code, message)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Client:
|
|
104
|
+
"""A connection to dotvault's Vault, following dotvault's own conventions.
|
|
105
|
+
|
|
106
|
+
Construction loads dotvault's system config and builds the underlying Vault
|
|
107
|
+
client; it performs no network call and does not authenticate. Call
|
|
108
|
+
:meth:`authenticate_cached` before reading.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
config_path: Path to dotvault's system config. ``None`` uses
|
|
112
|
+
:func:`default_config_path`.
|
|
113
|
+
identity: Override for the ``<user>`` path segment. ``None`` derives it
|
|
114
|
+
from the OS account (the common case — a per-user daemon). Set it
|
|
115
|
+
explicitly when the process runs as a different OS user than the
|
|
116
|
+
dotvault that wrote the secrets.
|
|
117
|
+
|
|
118
|
+
Use as a context manager, or call :meth:`close` to release the native
|
|
119
|
+
handle deterministically. The object is also cleaned up on GC.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, config_path: str | None = None, identity: str | None = None) -> None:
|
|
123
|
+
err = c_void_p()
|
|
124
|
+
handle = _ffi.lib.dotvault_client_new(
|
|
125
|
+
_ffi.encode(config_path), _ffi.encode(identity), byref(err)
|
|
126
|
+
)
|
|
127
|
+
if handle == _CLOSED:
|
|
128
|
+
# Config-load/validation failures are not facade sentinels; the
|
|
129
|
+
# bridge returns 0 with the detail in the error string.
|
|
130
|
+
raise DotvaultError(_ffi.take_str(err) or "dotvault: failed to create client")
|
|
131
|
+
self._handle = handle
|
|
132
|
+
|
|
133
|
+
def authenticate_cached(self, timeout: float | None = None) -> None:
|
|
134
|
+
"""Resolve and validate a cached token; never prompts.
|
|
135
|
+
|
|
136
|
+
Resolves ``DOTVAULT_TOKEN`` then the configured token file and validates
|
|
137
|
+
it against Vault. Returns ``None`` on success.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
LoginRequired: No usable cached token.
|
|
141
|
+
Unreachable: Vault could not be reached to validate the token.
|
|
142
|
+
DotvaultError: The client handle is invalid (use-after-close).
|
|
143
|
+
"""
|
|
144
|
+
self._require_open()
|
|
145
|
+
err = c_void_p()
|
|
146
|
+
code = _ffi.lib.dotvault_authenticate_cached(self._handle, _millis(timeout), byref(err))
|
|
147
|
+
_check(code, err)
|
|
148
|
+
|
|
149
|
+
def identity_name(self) -> str:
|
|
150
|
+
"""Return the ``<user>`` path segment (the OS user, or the override).
|
|
151
|
+
|
|
152
|
+
No Vault call — a local OS lookup. This is the value
|
|
153
|
+
:meth:`read_user_secret` composes paths with.
|
|
154
|
+
"""
|
|
155
|
+
self._require_open()
|
|
156
|
+
out = c_void_p()
|
|
157
|
+
err = c_void_p()
|
|
158
|
+
code = _ffi.lib.dotvault_identity_name(self._handle, byref(out), byref(err))
|
|
159
|
+
_check(code, err)
|
|
160
|
+
return _ffi.take_str(out) or ""
|
|
161
|
+
|
|
162
|
+
def token(self) -> str:
|
|
163
|
+
"""Return the Vault token the client currently holds, or ``""``.
|
|
164
|
+
|
|
165
|
+
Useful to hand the same token to other Vault-aware tooling. Empty until
|
|
166
|
+
a successful :meth:`authenticate_cached`.
|
|
167
|
+
"""
|
|
168
|
+
self._require_open()
|
|
169
|
+
out = c_void_p()
|
|
170
|
+
err = c_void_p()
|
|
171
|
+
code = _ffi.lib.dotvault_token(self._handle, byref(out), byref(err))
|
|
172
|
+
_check(code, err)
|
|
173
|
+
return _ffi.take_str(out) or ""
|
|
174
|
+
|
|
175
|
+
def read_kv_field(
|
|
176
|
+
self, mount: str, path: str, field: str, timeout: float | None = None
|
|
177
|
+
) -> str | None:
|
|
178
|
+
"""Read one field of a KV v2 secret, or ``None`` if absent.
|
|
179
|
+
|
|
180
|
+
Returns the field value, or ``None`` when the secret or the field does
|
|
181
|
+
not exist (a missing path and a missing field are not distinguished —
|
|
182
|
+
both are "not there"). A missing or disabled KV mount also reads as
|
|
183
|
+
``None`` rather than an error, matching the Go facade.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
Denied: Vault rejected the read (401/403).
|
|
187
|
+
Unreachable: Vault could not be reached.
|
|
188
|
+
"""
|
|
189
|
+
self._require_open()
|
|
190
|
+
out = c_void_p()
|
|
191
|
+
found = c_int(0)
|
|
192
|
+
err = c_void_p()
|
|
193
|
+
code = _ffi.lib.dotvault_read_kv_field(
|
|
194
|
+
self._handle,
|
|
195
|
+
_ffi.encode(mount),
|
|
196
|
+
_ffi.encode(path),
|
|
197
|
+
_ffi.encode(field),
|
|
198
|
+
_millis(timeout),
|
|
199
|
+
byref(out),
|
|
200
|
+
byref(found),
|
|
201
|
+
byref(err),
|
|
202
|
+
)
|
|
203
|
+
_check(code, err)
|
|
204
|
+
return _ffi.take_str(out) if found.value else None
|
|
205
|
+
|
|
206
|
+
def read_user_secret(
|
|
207
|
+
self, service: str, field: str, timeout: float | None = None
|
|
208
|
+
) -> str | None:
|
|
209
|
+
"""Read one field of ``kv/users/<identity>/<service>``, or ``None``.
|
|
210
|
+
|
|
211
|
+
Composes the path from dotvault's configured KV mount and user prefix,
|
|
212
|
+
:meth:`identity_name`, and ``service``. Return semantics match
|
|
213
|
+
:meth:`read_kv_field`.
|
|
214
|
+
|
|
215
|
+
Example: ``read_user_secret("gh", "oauth_token")`` reads the
|
|
216
|
+
``oauth_token`` field of the github enrolment.
|
|
217
|
+
"""
|
|
218
|
+
self._require_open()
|
|
219
|
+
out = c_void_p()
|
|
220
|
+
found = c_int(0)
|
|
221
|
+
err = c_void_p()
|
|
222
|
+
code = _ffi.lib.dotvault_read_user_secret(
|
|
223
|
+
self._handle,
|
|
224
|
+
_ffi.encode(service),
|
|
225
|
+
_ffi.encode(field),
|
|
226
|
+
_millis(timeout),
|
|
227
|
+
byref(out),
|
|
228
|
+
byref(found),
|
|
229
|
+
byref(err),
|
|
230
|
+
)
|
|
231
|
+
_check(code, err)
|
|
232
|
+
return _ffi.take_str(out) if found.value else None
|
|
233
|
+
|
|
234
|
+
def close(self) -> None:
|
|
235
|
+
"""Release the native handle. Idempotent; safe to call more than once."""
|
|
236
|
+
if getattr(self, "_handle", _CLOSED) != _CLOSED:
|
|
237
|
+
_ffi.lib.dotvault_client_free(c_longlong(self._handle))
|
|
238
|
+
self._handle = _CLOSED
|
|
239
|
+
|
|
240
|
+
def _require_open(self) -> None:
|
|
241
|
+
if getattr(self, "_handle", _CLOSED) == _CLOSED:
|
|
242
|
+
raise DotvaultError("dotvault: client is closed")
|
|
243
|
+
|
|
244
|
+
def __enter__(self) -> "Client":
|
|
245
|
+
return self
|
|
246
|
+
|
|
247
|
+
def __exit__(self, *exc: object) -> None:
|
|
248
|
+
self.close()
|
|
249
|
+
|
|
250
|
+
def __del__(self) -> None:
|
|
251
|
+
# Best-effort cleanup on GC; interpreter teardown may have already
|
|
252
|
+
# dropped _ffi, so guard against that.
|
|
253
|
+
try:
|
|
254
|
+
self.close()
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
dotvault/_dotvault.dll
ADDED
|
Binary file
|
dotvault/_errors.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Exception taxonomy mirroring the Go facade's ``errors.Is``-able sentinels.
|
|
2
|
+
|
|
3
|
+
The integer category codes here are the SAME values the cgo bridge returns
|
|
4
|
+
across the ABI (see ``cat*`` in ``python/bridge/bridge.go``). The two must be
|
|
5
|
+
extended together — a new sentinel needs a new code on both sides and a new
|
|
6
|
+
exception class below.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Category codes, mirrored verbatim from the Go bridge.
|
|
12
|
+
CAT_OK = 0
|
|
13
|
+
CAT_LOGIN_REQUIRED = 1
|
|
14
|
+
CAT_DENIED = 2
|
|
15
|
+
CAT_UNREACHABLE = 3
|
|
16
|
+
CAT_AUTH_FAILED = 4
|
|
17
|
+
CAT_OTHER = 5
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DotvaultError(Exception):
|
|
21
|
+
"""Base class for every error raised by this package.
|
|
22
|
+
|
|
23
|
+
Catch this to handle any dotvault failure generically; catch a subclass to
|
|
24
|
+
distinguish the categories below. An error that matches no specific
|
|
25
|
+
category (a config-load/validation failure, an unknown handle) is raised as
|
|
26
|
+
a bare ``DotvaultError``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LoginRequired(DotvaultError):
|
|
31
|
+
"""No usable cached token was found and no interactive login was attempted.
|
|
32
|
+
|
|
33
|
+
Maps to the facade's ``ErrLoginRequired``. The remedy is to provision a
|
|
34
|
+
token out of band — run ``dotvault login`` (or let the daemon do it). This
|
|
35
|
+
package never prompts interactively.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthFailed(DotvaultError):
|
|
40
|
+
"""A login flow ran but did not yield a usable token (``ErrAuthFailed``).
|
|
41
|
+
|
|
42
|
+
Read-only callers will rarely see this — it surfaces only if a future
|
|
43
|
+
interactive path is added — but the category exists so the mapping is total.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Denied(DotvaultError):
|
|
48
|
+
"""Vault rejected a read with 401/403 (``ErrDenied``).
|
|
49
|
+
|
|
50
|
+
The token is valid but lacks the policy for the path, or was revoked
|
|
51
|
+
between the auth check and the read.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Unreachable(DotvaultError):
|
|
56
|
+
"""Vault could not be reached or could not service the request now.
|
|
57
|
+
|
|
58
|
+
DNS/connection/TLS/timeout, or a 5xx/429 from the server (``ErrUnreachable``).
|
|
59
|
+
Retryable — back off and try again.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_BY_CATEGORY = {
|
|
64
|
+
CAT_LOGIN_REQUIRED: LoginRequired,
|
|
65
|
+
CAT_DENIED: Denied,
|
|
66
|
+
CAT_UNREACHABLE: Unreachable,
|
|
67
|
+
CAT_AUTH_FAILED: AuthFailed,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def error_for(code: int, message: str | None) -> DotvaultError:
|
|
72
|
+
"""Build the exception for a non-OK category code and message."""
|
|
73
|
+
cls = _BY_CATEGORY.get(code, DotvaultError)
|
|
74
|
+
return cls(message or "dotvault: unknown error")
|
dotvault/_ffi.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""ctypes binding to the cgo c-shared bridge (``_dotvault.<ext>``).
|
|
2
|
+
|
|
3
|
+
This module owns three things and nothing else: locating and loading the shared
|
|
4
|
+
library that ships inside the package, declaring the C signatures of every
|
|
5
|
+
exported symbol, and the low-level helpers for moving owned C strings across
|
|
6
|
+
the boundary. The ergonomic surface (``Client``) lives in ``__init__``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
import os
|
|
13
|
+
from ctypes import POINTER, c_char_p, c_int, c_longlong, c_void_p
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _library_path() -> str:
|
|
17
|
+
"""Locate the bundled shared library next to this module.
|
|
18
|
+
|
|
19
|
+
The artifact is named ``_dotvault`` plus the platform extension
|
|
20
|
+
(``.so`` / ``.dylib`` / ``.dll``) and is dropped into the package directory
|
|
21
|
+
at build time. We match by prefix rather than hard-coding the extension so
|
|
22
|
+
the same code loads on every platform.
|
|
23
|
+
"""
|
|
24
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
25
|
+
for name in sorted(os.listdir(here)):
|
|
26
|
+
if name.startswith("_dotvault.") and not name.endswith((".py", ".pyi", ".h")):
|
|
27
|
+
return os.path.join(here, name)
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"dotvault: native library not found next to "
|
|
30
|
+
f"{here!r}; the package was built without its compiled bridge"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_lib = ctypes.CDLL(_library_path())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _declare(name: str, restype, argtypes) -> None:
|
|
38
|
+
fn = getattr(_lib, name)
|
|
39
|
+
fn.restype = restype
|
|
40
|
+
fn.argtypes = argtypes
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Owned out-strings are typed as c_void_p (not c_char_p) so ctypes hands back
|
|
44
|
+
# the raw pointer rather than auto-copying-and-dropping it — we need the pointer
|
|
45
|
+
# intact to hand to dotvault_free. take_str() does the read-then-free.
|
|
46
|
+
_declare("dotvault_default_config_path", c_void_p, [])
|
|
47
|
+
_declare("dotvault_free", None, [c_void_p])
|
|
48
|
+
_declare("dotvault_client_new", c_longlong, [c_char_p, c_char_p, POINTER(c_void_p)])
|
|
49
|
+
_declare("dotvault_client_free", None, [c_longlong])
|
|
50
|
+
_declare("dotvault_authenticate_cached", c_int, [c_longlong, c_longlong, POINTER(c_void_p)])
|
|
51
|
+
_declare("dotvault_identity_name", c_int, [c_longlong, POINTER(c_void_p), POINTER(c_void_p)])
|
|
52
|
+
_declare("dotvault_token", c_int, [c_longlong, POINTER(c_void_p), POINTER(c_void_p)])
|
|
53
|
+
_declare(
|
|
54
|
+
"dotvault_read_kv_field",
|
|
55
|
+
c_int,
|
|
56
|
+
[c_longlong, c_char_p, c_char_p, c_char_p, c_longlong, POINTER(c_void_p), POINTER(c_int), POINTER(c_void_p)],
|
|
57
|
+
)
|
|
58
|
+
_declare(
|
|
59
|
+
"dotvault_read_user_secret",
|
|
60
|
+
c_int,
|
|
61
|
+
[c_longlong, c_char_p, c_char_p, c_longlong, POINTER(c_void_p), POINTER(c_int), POINTER(c_void_p)],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
lib = _lib
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def take_str(ptr: int | c_void_p | None) -> str | None:
|
|
68
|
+
"""Decode an owned C string and free it; ``None``/NULL yields ``None``.
|
|
69
|
+
|
|
70
|
+
Every string the bridge writes out is malloc'd on the Go side and owned by
|
|
71
|
+
us. This reads it as UTF-8 and releases it with dotvault_free, so a single
|
|
72
|
+
call site can't leak. ``ptr`` is whatever a ``c_void_p`` out-param holds
|
|
73
|
+
(an int) or a NULL.
|
|
74
|
+
"""
|
|
75
|
+
value = ptr.value if isinstance(ptr, c_void_p) else ptr
|
|
76
|
+
if not value:
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
raw = ctypes.cast(value, c_char_p).value
|
|
80
|
+
return raw.decode("utf-8") if raw is not None else None
|
|
81
|
+
finally:
|
|
82
|
+
_lib.dotvault_free(value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def encode(s: str | None) -> bytes | None:
|
|
86
|
+
"""Encode a Python str to NUL-terminable UTF-8 bytes for a c_char_p arg."""
|
|
87
|
+
return None if s is None else s.encode("utf-8")
|
dotvault/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotvault
|
|
3
|
+
Version: 0.24.0
|
|
4
|
+
Summary: Python bindings for the dotvault public client API (read per-user Vault secrets dotvault enrols)
|
|
5
|
+
Author: dotvault authors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/goodtune/dotvault
|
|
8
|
+
Project-URL: Repository, https://github.com/goodtune/dotvault
|
|
9
|
+
Keywords: vault,secrets,dotvault,hashicorp
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
18
|
+
|
|
19
|
+
# dotvault — Python bindings
|
|
20
|
+
|
|
21
|
+
Python bindings for [dotvault](https://github.com/goodtune/dotvault)'s public client API. They let a Python program read the per-user secrets a dotvault daemon enrolled and keeps current — talking to the same Vault, resolving a token the same way, and reading from the exact `kv/users/<user>/...` path dotvault writes to.
|
|
22
|
+
|
|
23
|
+
The package is a thin [ctypes](https://docs.python.org/3/library/ctypes.html) wrapper over dotvault's Go `client` package compiled to a shared library with `go build -buildmode=c-shared`. Connectivity, token precedence, the OS-user identity convention, and the path layout all come from the one canonical Go implementation rather than being re-derived in Python, so a consumer can't silently diverge from the daemon. The native bridge imports only the public Go `client` package — never dotvault's internals.
|
|
24
|
+
|
|
25
|
+
## Scope
|
|
26
|
+
|
|
27
|
+
This first release exposes the **read-only + cached-auth** subset of the Go facade:
|
|
28
|
+
|
|
29
|
+
- `Client(config_path=None, identity=None)` — load config, build the client.
|
|
30
|
+
- `authenticate_cached(timeout=None)` — resolve a token (`DOTVAULT_TOKEN` → token file) and validate it. **Never prompts.**
|
|
31
|
+
- `identity_name()`, `token()`.
|
|
32
|
+
- `read_user_secret(service, field, timeout=None)`, `read_kv_field(mount, path, field, timeout=None)`.
|
|
33
|
+
|
|
34
|
+
Interactive login (OIDC browser pop, LDAP password + MFA terminal prompts) is **deliberately out of scope** — driving it across an FFI boundary from inside a Python process is awkward and not what a library caller wants. Provision a token out of band (`dotvault login`, or the daemon) and these bindings consume it.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Released wheels are published to PyPI (Linux `manylinux_2_28`, macOS arm64, Windows x86_64):
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install dotvault # or: uv pip install dotvault
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The wheel is tagged `py3-none-<platform>`: it carries a native shared library (so it is platform-specific) but contains no CPython C-extension — it is pure ctypes — so a single wheel per OS installs on **any** Python ≥ 3.9, not one wheel per interpreter version. Its version is derived from the repo's git tags by `setuptools-scm` (the same tags that version the daemon); `dotvault.__version__` reports it.
|
|
45
|
+
|
|
46
|
+
Wheels are built for glibc Linux x86_64 (`manylinux_2_28`), Apple-Silicon macOS, and Windows x86_64. There are currently no wheels for Linux aarch64, musl/Alpine, Intel macOS, or Windows arm64 — `pip install` on those reports "no matching distribution"; build from a checkout instead.
|
|
47
|
+
|
|
48
|
+
To build from a checkout instead — the tooling is [uv](https://docs.astral.dev/uv/):
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
make python-wheel # -> python/dist/dotvault-*.whl (runs `uv build`)
|
|
52
|
+
uv pip install python/dist/dotvault-*.whl
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
or an editable install (requires Go on `PATH`, since the bridge is compiled on install):
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
cd python && uv pip install -e .
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Building from source needs the Go toolchain and a C compiler — `go build -buildmode=c-shared` is the one place dotvault uses cgo (`CGO_ENABLED=1`). The main dotvault binaries remain pure-Go static builds; only this binding links libc. On Windows the C compiler must be a mingw-w64 gcc (the `c-shared` build does not work with MSVC); GitHub's `windows-latest` runners ship one, but a local Windows build needs it on `PATH`.
|
|
62
|
+
|
|
63
|
+
Concurrency note: set `DOTVAULT_TOKEN` before the first use of a `Client`, not concurrently with reads from another thread. The bridge re-reads the facade's env vars from libc on each token-resolving call; that read/write is serialised internally, but it cannot be made safe against the host process mutating its own environment from another thread.
|
|
64
|
+
|
|
65
|
+
## Quick start
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import dotvault
|
|
69
|
+
|
|
70
|
+
# Defaults: dotvault's system config path, identity = the OS user.
|
|
71
|
+
with dotvault.Client() as c:
|
|
72
|
+
try:
|
|
73
|
+
c.authenticate_cached(timeout=5) # env -> token file; no prompt
|
|
74
|
+
except dotvault.LoginRequired:
|
|
75
|
+
raise SystemExit("run `dotvault login` first")
|
|
76
|
+
except dotvault.Unreachable:
|
|
77
|
+
raise SystemExit("vault is unreachable; retry later")
|
|
78
|
+
|
|
79
|
+
token = c.read_user_secret("gh", "oauth_token") # -> str | None
|
|
80
|
+
if token is None:
|
|
81
|
+
raise SystemExit("github enrolment not present")
|
|
82
|
+
use(token)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A read returns the field value, or `None` when the secret or field is absent — a missing path and a missing field are not distinguished (both are "not there"), matching the Go facade. Transport/authorisation failures raise instead.
|
|
86
|
+
|
|
87
|
+
## Identity is the OS user, not the Vault token
|
|
88
|
+
|
|
89
|
+
dotvault derives the `<user>` segment of `kv/users/<user>/...` from the **OS account the process runs as** (with any `DOMAIN\` prefix stripped), *not* from the Vault token's `display_name` or entity. By default a consumer must therefore run as the **same OS user** as the dotvault that populated the secrets — normally true for a per-user daemon.
|
|
90
|
+
|
|
91
|
+
If your process runs as a different user (a service account, a container), pass `identity=` to read that user's secrets:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
dotvault.Client(identity="alice")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A wrong identity reads a non-existent path, which surfaces as a `None` read — not an error.
|
|
98
|
+
|
|
99
|
+
## Errors
|
|
100
|
+
|
|
101
|
+
Every failure is a `DotvaultError` or a subclass:
|
|
102
|
+
|
|
103
|
+
| Exception | Meaning | What to do |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `LoginRequired` | No usable cached token. | Provision a token (`dotvault login`). |
|
|
106
|
+
| `Unreachable` | Vault down / 5xx / timeout. | Retry, back off. |
|
|
107
|
+
| `Denied` | Vault rejected the read (401/403). | Fail closed; the token lacks the policy. |
|
|
108
|
+
| `AuthFailed` | A login ran but failed. | Surface the auth problem (rare on this surface). |
|
|
109
|
+
| `DotvaultError` | Anything else (config load, closed client). | Fail closed. |
|
|
110
|
+
|
|
111
|
+
A not-found read is `None`, never an exception.
|
|
112
|
+
|
|
113
|
+
## Environment variables
|
|
114
|
+
|
|
115
|
+
The bindings honour the same variables as dotvault: `DOTVAULT_TOKEN` (a token supplied via the environment) and `VAULT_NAMESPACE`. `VAULT_TOKEN` is **deliberately ignored** — it belongs to the `vault` CLI and must not leak into dotvault's session; use `DOTVAULT_TOKEN`.
|
|
116
|
+
|
|
117
|
+
The Go runtime snapshots its environment at load time, so the bridge re-reads these two variables from the live process environment on each call. That means setting `os.environ["DOTVAULT_TOKEN"]` after `import dotvault` works as you'd expect.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
make python-lib # build the native bridge into the package for local use
|
|
123
|
+
make python-test # build the bridge + run the Python test suite (via `uv run`)
|
|
124
|
+
go test ./python/bridge/... # the Go-side bridge unit tests
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`make python-test` and `make python-wheel` shell out to `uv`, which provisions Python and the test/build dependencies; only the Go toolchain and a C compiler need to be present beforehand.
|
|
128
|
+
|
|
129
|
+
The Python tests run fully offline — they point at a closed Vault port and assert the error categorisation — so no live Vault is needed.
|
|
130
|
+
|
|
131
|
+
## Releasing
|
|
132
|
+
|
|
133
|
+
Publishing a GitHub Release with a `vX.Y.Z` tag drives both the Go release (`release.yml`) and the Python wheels: the `.github/workflows/python.yml` `publish` job builds the per-OS wheels and uploads them to PyPI via the official `pypa/gh-action-pypi-publish` action using **Trusted Publishing** (OIDC — no stored token). One-time setup before the first release: register a [pending publisher](https://docs.pypi.org/trusted-publishers/) on PyPI for project `dotvault` pointing at this repository, workflow `python.yml`, and environment `pypi`. A dev/untagged build produces a PEP 440 local version that PyPI rejects, so only exact tagged releases publish. Consider a one-off TestPyPI dry run (temporarily pointing the action at TestPyPI) before the first real release to validate the OIDC/publisher wiring.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dotvault/__init__.py,sha256=adEqLW4IlgiPoj365CN8J_6QxISUQawzow17NAa-cDA,9784
|
|
2
|
+
dotvault/_dotvault.dll,sha256=vSEXIzxNJig-rmi4UbX9QlLalNraHVcQOrAA1z9f67Y,30686808
|
|
3
|
+
dotvault/_errors.py,sha256=Fr9F94TnmPsaBamOp3ao2vqVYN4XXAOvFxH7GUoyCq0,2418
|
|
4
|
+
dotvault/_ffi.py,sha256=DnrfKApxGbICuV7jsvpP6Lrtc2_pjD0d7thk5a1OTYE,3445
|
|
5
|
+
dotvault/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
dotvault-0.24.0.dist-info/METADATA,sha256=XK33JZJUk33_Cs-7GUrjIQVSkYIct4a4buwM5_dqQfo,8459
|
|
7
|
+
dotvault-0.24.0.dist-info/WHEEL,sha256=GjDPPQwEcripVP6P2r3RxLa-h5Lb9ifGB7FYYtbLDT0,98
|
|
8
|
+
dotvault-0.24.0.dist-info/top_level.txt,sha256=6kZ7eYVAly2rbAaWemF8nBQ1Fm2Oi-LU-SRblxeqYyA,9
|
|
9
|
+
dotvault-0.24.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dotvault
|