ranbval-sdk 1.4.0__py3-none-any.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.
- ranbval_sdk/__init__.py +115 -0
- ranbval_sdk/_internal/__init__.py +5 -0
- ranbval_sdk/_internal/defaults.py +11 -0
- ranbval_sdk/_internal/logging.py +17 -0
- ranbval_sdk/_internal/transport.py +17 -0
- ranbval_sdk/config/__init__.py +45 -0
- ranbval_sdk/config/access.py +367 -0
- ranbval_sdk/config/declarative.py +72 -0
- ranbval_sdk/config/loader.py +312 -0
- ranbval_sdk/config/manifest.py +61 -0
- ranbval_sdk/crypto/__init__.py +27 -0
- ranbval_sdk/crypto/audit.py +77 -0
- ranbval_sdk/crypto/cipher.py +284 -0
- ranbval_sdk/crypto/repo_policy.py +22 -0
- ranbval_sdk/crypto/secret_string.py +262 -0
- ranbval_sdk/exceptions.py +109 -0
- ranbval_sdk/integrations/__init__.py +1 -0
- ranbval_sdk/integrations/factory.py +58 -0
- ranbval_sdk/integrations/proxy.py +204 -0
- ranbval_sdk/integrations/universal.py +135 -0
- ranbval_sdk/policy/__init__.py +20 -0
- ranbval_sdk/policy/repo.py +159 -0
- ranbval_sdk/py.typed +0 -0
- ranbval_sdk/serializers/__init__.py +32 -0
- ranbval_sdk/serializers/audit.py +20 -0
- ranbval_sdk/serializers/proxy.py +37 -0
- ranbval_sdk/serializers/telemetry.py +77 -0
- ranbval_sdk/serializers/token.py +18 -0
- ranbval_sdk/telemetry/__init__.py +22 -0
- ranbval_sdk/telemetry/client.py +135 -0
- ranbval_sdk/telemetry/context.py +100 -0
- ranbval_sdk/telemetry/decorators.py +97 -0
- ranbval_sdk/telemetry/sampling.py +104 -0
- ranbval_sdk/telemetry/settings.py +35 -0
- ranbval_sdk-1.4.0.dist-info/METADATA +675 -0
- ranbval_sdk-1.4.0.dist-info/RECORD +38 -0
- ranbval_sdk-1.4.0.dist-info/WHEEL +4 -0
- ranbval_sdk-1.4.0.dist-info/licenses/LICENSE +21 -0
ranbval_sdk/__init__.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Ranbval SDK — keep API secrets out of plaintext config.
|
|
2
|
+
|
|
3
|
+
Encrypted vault tokens live in your ``.ranbval`` file; the SDK decrypts them only at the
|
|
4
|
+
moment of use and never lets the plaintext reach a ``print``, log, or ``repr``.
|
|
5
|
+
|
|
6
|
+
Public API, grouped by concern (each name re-exported from its home subpackage):
|
|
7
|
+
|
|
8
|
+
- **Config** (:mod:`ranbval_sdk.config`) — ``load_ranbval``, ``get_project_key``,
|
|
9
|
+
``find_ranbval_file``, ``find_ranbval_directory``, ``resolve_ranbval_mode``.
|
|
10
|
+
- **Access** (:mod:`ranbval_sdk.config.access`) — ``Vault``, ``env``, ``inject``,
|
|
11
|
+
``secrets``, ``iter_secrets``, ``public``, ``public_config``, ``is_public``,
|
|
12
|
+
``Secret``, ``SecretConfig``, ``SecretProvider``.
|
|
13
|
+
- **Crypto** (:mod:`ranbval_sdk.crypto`) — ``safe_decrypt``, ``decrypt_key``,
|
|
14
|
+
``SecretString``, ``get_audit_log``, ``clear_audit_log``, ``audit_scope``.
|
|
15
|
+
- **Telemetry** (:mod:`ranbval_sdk.telemetry`) — ``emit_telemetry``, ``aemit_telemetry``,
|
|
16
|
+
``track``, ``tracked``.
|
|
17
|
+
- **Integrations** (:mod:`ranbval_sdk.integrations`) — ``secure_client``, ``build_secure_client``.
|
|
18
|
+
- **Secure proxy** (:mod:`ranbval_sdk.integrations.proxy`) — ``proxy_request``, ``aproxy_request``.
|
|
19
|
+
- **Exceptions** (:mod:`ranbval_sdk.exceptions`) — ``RanbvalError`` and its subclasses.
|
|
20
|
+
|
|
21
|
+
Basic use::
|
|
22
|
+
|
|
23
|
+
from ranbval_sdk import load_ranbval, decrypt_key
|
|
24
|
+
load_ranbval()
|
|
25
|
+
client = openai.OpenAI(api_key=decrypt_key("OPENAI_API_KEY").use())
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from ranbval_sdk.config import (
|
|
29
|
+
Secret,
|
|
30
|
+
SecretConfig,
|
|
31
|
+
SecretProvider,
|
|
32
|
+
Vault,
|
|
33
|
+
env,
|
|
34
|
+
find_ranbval_directory,
|
|
35
|
+
find_ranbval_file,
|
|
36
|
+
get_project_key,
|
|
37
|
+
inject,
|
|
38
|
+
is_public,
|
|
39
|
+
iter_secrets,
|
|
40
|
+
load_ranbval,
|
|
41
|
+
public,
|
|
42
|
+
public_config,
|
|
43
|
+
resolve_ranbval_mode,
|
|
44
|
+
secrets,
|
|
45
|
+
)
|
|
46
|
+
from ranbval_sdk.crypto import (
|
|
47
|
+
SecretString,
|
|
48
|
+
audit_scope,
|
|
49
|
+
clear_audit_log,
|
|
50
|
+
decrypt_key,
|
|
51
|
+
get_audit_log,
|
|
52
|
+
safe_decrypt,
|
|
53
|
+
)
|
|
54
|
+
from ranbval_sdk.exceptions import (
|
|
55
|
+
MissingKeyError,
|
|
56
|
+
ProxyError,
|
|
57
|
+
RanbvalConfigError,
|
|
58
|
+
RanbvalDecryptError,
|
|
59
|
+
RanbvalError,
|
|
60
|
+
RepoNotAllowedError,
|
|
61
|
+
RepoPolicyError,
|
|
62
|
+
)
|
|
63
|
+
from ranbval_sdk.integrations.factory import secure_client
|
|
64
|
+
from ranbval_sdk.integrations.proxy import aproxy_request, proxy_request
|
|
65
|
+
from ranbval_sdk.integrations.universal import build_secure_client
|
|
66
|
+
from ranbval_sdk.telemetry import aemit_telemetry, emit_telemetry, track, tracked
|
|
67
|
+
|
|
68
|
+
__version__ = "1.4.0"
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
# Config
|
|
72
|
+
"load_ranbval",
|
|
73
|
+
"get_project_key",
|
|
74
|
+
"find_ranbval_file",
|
|
75
|
+
"find_ranbval_directory",
|
|
76
|
+
"resolve_ranbval_mode",
|
|
77
|
+
# Access
|
|
78
|
+
"Vault",
|
|
79
|
+
"env",
|
|
80
|
+
"inject",
|
|
81
|
+
"secrets",
|
|
82
|
+
"iter_secrets",
|
|
83
|
+
"public",
|
|
84
|
+
"public_config",
|
|
85
|
+
"is_public",
|
|
86
|
+
"Secret",
|
|
87
|
+
"SecretConfig",
|
|
88
|
+
"SecretProvider",
|
|
89
|
+
# Crypto
|
|
90
|
+
"safe_decrypt",
|
|
91
|
+
"decrypt_key",
|
|
92
|
+
"SecretString",
|
|
93
|
+
"get_audit_log",
|
|
94
|
+
"clear_audit_log",
|
|
95
|
+
"audit_scope",
|
|
96
|
+
# Telemetry
|
|
97
|
+
"emit_telemetry",
|
|
98
|
+
"aemit_telemetry",
|
|
99
|
+
"track",
|
|
100
|
+
"tracked",
|
|
101
|
+
# Integrations
|
|
102
|
+
"secure_client",
|
|
103
|
+
"build_secure_client",
|
|
104
|
+
# Secure proxy
|
|
105
|
+
"proxy_request",
|
|
106
|
+
"aproxy_request",
|
|
107
|
+
# Exceptions
|
|
108
|
+
"RanbvalError",
|
|
109
|
+
"RanbvalDecryptError",
|
|
110
|
+
"RanbvalConfigError",
|
|
111
|
+
"MissingKeyError",
|
|
112
|
+
"RepoNotAllowedError",
|
|
113
|
+
"RepoPolicyError",
|
|
114
|
+
"ProxyError",
|
|
115
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Shared default constants for optional configuration.
|
|
2
|
+
|
|
3
|
+
Override with environment variables when needed (e.g. self-hosted or local dev).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Password-manager origin only — no ``/api`` suffix (SDK appends ``/api/...`` paths).
|
|
7
|
+
DEFAULT_RANBVAL_HOST = "https://api.ranbval.com"
|
|
8
|
+
|
|
9
|
+
# The stderr debug warning moved to ``_internal.logging``; re-exported here so any
|
|
10
|
+
# existing ``from ranbval_sdk._internal.defaults import warn_telemetry_send_failed`` keeps working.
|
|
11
|
+
from ranbval_sdk._internal.logging import warn_telemetry_send_failed # noqa: E402,F401
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Internal diagnostics — opt-in stderr warnings for otherwise-silent failures.
|
|
2
|
+
|
|
3
|
+
The SDK never prints by default. Setting ``RANBVAL_TELEMETRY_DEBUG=1`` surfaces why a
|
|
4
|
+
best-effort telemetry POST failed, so CI can debug it without changing code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def warn_telemetry_send_failed(host: str, exc: BaseException) -> None:
|
|
12
|
+
"""If ``RANBVAL_TELEMETRY_DEBUG=1``, print why POST /api/telemetry failed (default is silent)."""
|
|
13
|
+
v = (os.environ.get("RANBVAL_TELEMETRY_DEBUG") or "").strip().lower()
|
|
14
|
+
if v not in ("1", "true", "yes", "on"):
|
|
15
|
+
return
|
|
16
|
+
url = f"{host.rstrip('/')}/api/telemetry"
|
|
17
|
+
print(f"[Ranbval] Telemetry POST failed ({url}): {exc!r}", file=sys.stderr)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""HTTPS via urllib with certifi's CA bundle (fixes common macOS SSL verify failures)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ssl
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def urlopen(req: urllib.request.Request, timeout: float | None = None) -> Any:
|
|
11
|
+
full = req.get_full_url()
|
|
12
|
+
if full.lower().startswith("https:"):
|
|
13
|
+
import certifi
|
|
14
|
+
|
|
15
|
+
ctx = ssl.create_default_context(cafile=certifi.where())
|
|
16
|
+
return urllib.request.urlopen(req, timeout=timeout, context=ctx)
|
|
17
|
+
return urllib.request.urlopen(req, timeout=timeout)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Configuration: loading ``.ranbval`` files and accessing the values in them.
|
|
2
|
+
|
|
3
|
+
- :mod:`~ranbval_sdk.config.loader` — parse and merge layered ``.ranbval*`` files into the env.
|
|
4
|
+
- :mod:`~ranbval_sdk.config.access` — imperative access (``Vault``, ``inject``, ``secrets``).
|
|
5
|
+
- :mod:`~ranbval_sdk.config.declarative` — class-based access (``Secret``, ``SecretConfig``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ranbval_sdk.config.access import (
|
|
9
|
+
SecretProvider,
|
|
10
|
+
Vault,
|
|
11
|
+
env,
|
|
12
|
+
inject,
|
|
13
|
+
is_public,
|
|
14
|
+
iter_secrets,
|
|
15
|
+
public,
|
|
16
|
+
public_config,
|
|
17
|
+
secrets,
|
|
18
|
+
)
|
|
19
|
+
from ranbval_sdk.config.declarative import Secret, SecretConfig
|
|
20
|
+
from ranbval_sdk.config.loader import (
|
|
21
|
+
find_ranbval_directory,
|
|
22
|
+
find_ranbval_file,
|
|
23
|
+
get_project_key,
|
|
24
|
+
load_ranbval,
|
|
25
|
+
resolve_ranbval_mode,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"load_ranbval",
|
|
30
|
+
"get_project_key",
|
|
31
|
+
"find_ranbval_file",
|
|
32
|
+
"find_ranbval_directory",
|
|
33
|
+
"resolve_ranbval_mode",
|
|
34
|
+
"Vault",
|
|
35
|
+
"env",
|
|
36
|
+
"inject",
|
|
37
|
+
"secrets",
|
|
38
|
+
"iter_secrets",
|
|
39
|
+
"public",
|
|
40
|
+
"public_config",
|
|
41
|
+
"is_public",
|
|
42
|
+
"Secret",
|
|
43
|
+
"SecretConfig",
|
|
44
|
+
"SecretProvider",
|
|
45
|
+
]
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""High-level, ergonomic access to your ``.ranbval`` configuration surface.
|
|
2
|
+
|
|
3
|
+
Thin, readable wrappers over :func:`~ranbval_sdk.config.loader.load_ranbval` and
|
|
4
|
+
:func:`~ranbval_sdk.crypto.decrypt_key` so applications consume secrets with almost no
|
|
5
|
+
boilerplate — a :class:`Vault` mapping, an ``@inject`` decorator, a ``secrets()`` context
|
|
6
|
+
manager, and a :class:`Secret` descriptor. Decryption still happens only in
|
|
7
|
+
``crypto.safe_decrypt``; these are pure convenience. Secrets stay sealed by default.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import functools
|
|
14
|
+
import inspect
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Callable, Iterator
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
from ranbval_sdk.config.loader import load_ranbval
|
|
22
|
+
from ranbval_sdk.crypto.secret_string import SecretString
|
|
23
|
+
from ranbval_sdk.exceptions import MissingKeyError, RanbvalConfigError
|
|
24
|
+
|
|
25
|
+
_loaded_modes: set[str] = set()
|
|
26
|
+
_load_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
#: Sentinel so ``public(name)`` can tell "no default given" apart from ``default=None``.
|
|
29
|
+
_UNSET = object()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_token(value: str | None) -> bool:
|
|
33
|
+
"""A value is a Ranbval vault token when it carries the ``ranbval.`` prefix."""
|
|
34
|
+
return bool(value) and value.startswith("ranbval.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ensure_env_loaded(mode: str | None = None) -> None:
|
|
38
|
+
"""Load ``.ranbval*`` files for ``mode`` exactly once per process (thread-safe)."""
|
|
39
|
+
marker = mode or "__default__"
|
|
40
|
+
if marker in _loaded_modes:
|
|
41
|
+
return
|
|
42
|
+
with _load_lock:
|
|
43
|
+
if marker not in _loaded_modes:
|
|
44
|
+
load_ranbval(mode=mode)
|
|
45
|
+
_loaded_modes.add(marker)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve(env_var: str, *, reveal: bool) -> Any:
|
|
49
|
+
"""Decrypt a vault token or pass a plain value through; optionally reveal plaintext."""
|
|
50
|
+
raw = os.environ.get(env_var)
|
|
51
|
+
if raw is None:
|
|
52
|
+
raise MissingKeyError(
|
|
53
|
+
f"{env_var!r} is not set — did you create it in your .ranbval file?"
|
|
54
|
+
)
|
|
55
|
+
if not _is_token(raw):
|
|
56
|
+
return raw # ordinary, safe-to-commit config value
|
|
57
|
+
from ranbval_sdk.crypto import decrypt_key
|
|
58
|
+
|
|
59
|
+
secret = decrypt_key(env_var)
|
|
60
|
+
return secret.use() if reveal else secret
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@runtime_checkable
|
|
64
|
+
class SecretProvider(Protocol):
|
|
65
|
+
"""Anything that can hand back a plaintext secret by name."""
|
|
66
|
+
|
|
67
|
+
def reveal(self, name: str) -> str: ...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class _Options:
|
|
72
|
+
mode: str | None = None
|
|
73
|
+
override: bool = False
|
|
74
|
+
autoload: bool = True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Vault:
|
|
78
|
+
"""A lazy, dunder-rich view over your configuration surface.
|
|
79
|
+
|
|
80
|
+
Loads ``.ranbval*`` on first access, then serves values by attribute or item —
|
|
81
|
+
plain config as ``str``, secrets as :class:`SecretString` (decrypted once, cached)::
|
|
82
|
+
|
|
83
|
+
vault = Vault()
|
|
84
|
+
vault.OPENAI_API_KEY # -> SecretString
|
|
85
|
+
vault["DATABASE_URL"] # -> str (plain value)
|
|
86
|
+
vault.reveal("OPENAI_API_KEY") # -> plaintext str, one line
|
|
87
|
+
"STRIPE_KEY" in vault # membership test
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
__slots__ = ("_cache", "_loaded", "_opts", "_lock")
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self, *, mode: str | None = None, override: bool = False, autoload: bool = True
|
|
94
|
+
):
|
|
95
|
+
self._cache: dict[str, Any] = {}
|
|
96
|
+
self._loaded = False
|
|
97
|
+
self._lock = threading.Lock()
|
|
98
|
+
self._opts = _Options(mode=mode, override=override, autoload=autoload)
|
|
99
|
+
|
|
100
|
+
def _ensure_loaded(self) -> None:
|
|
101
|
+
if self._opts.autoload and not self._loaded:
|
|
102
|
+
with self._lock:
|
|
103
|
+
if not self._loaded:
|
|
104
|
+
load_ranbval(mode=self._opts.mode, override=self._opts.override)
|
|
105
|
+
self._loaded = True
|
|
106
|
+
|
|
107
|
+
def _get(self, name: str) -> Any:
|
|
108
|
+
self._ensure_loaded()
|
|
109
|
+
if name not in self._cache:
|
|
110
|
+
self._cache[name] = _resolve(name, reveal=False)
|
|
111
|
+
return self._cache[name]
|
|
112
|
+
|
|
113
|
+
# -- mapping protocol ---------------------------------------------------
|
|
114
|
+
def __getattr__(self, name: str) -> Any:
|
|
115
|
+
if name.startswith("_"):
|
|
116
|
+
raise AttributeError(name)
|
|
117
|
+
try:
|
|
118
|
+
return self._get(name)
|
|
119
|
+
except KeyError as exc:
|
|
120
|
+
raise AttributeError(str(exc)) from exc
|
|
121
|
+
|
|
122
|
+
def __getitem__(self, name: str) -> Any:
|
|
123
|
+
return self._get(name)
|
|
124
|
+
|
|
125
|
+
def __contains__(self, name: str) -> bool:
|
|
126
|
+
self._ensure_loaded()
|
|
127
|
+
return name in os.environ
|
|
128
|
+
|
|
129
|
+
def __iter__(self) -> Iterator[str]:
|
|
130
|
+
self._ensure_loaded()
|
|
131
|
+
return iter(os.environ)
|
|
132
|
+
|
|
133
|
+
# -- public (unencrypted) access ---------------------------------------
|
|
134
|
+
def public(self, name: str, default: Any = _UNSET) -> str:
|
|
135
|
+
"""Return a **plaintext** config value the public way — same policy as :func:`public`.
|
|
136
|
+
|
|
137
|
+
A key declared ``[secrets]`` (or any ``ranbval.*`` token) is refused, so a secret can
|
|
138
|
+
never be read through this public path::
|
|
139
|
+
|
|
140
|
+
env.public("DATABASE_URL") # -> plain str
|
|
141
|
+
env.public("OPENAI_API_KEY") # -> RanbvalConfigError (it's a secret)
|
|
142
|
+
"""
|
|
143
|
+
self._ensure_loaded()
|
|
144
|
+
return _lookup_public(name, default)
|
|
145
|
+
|
|
146
|
+
def public_config(self) -> dict[str, str]:
|
|
147
|
+
"""Every ``[public]``-declared key as ``{name: plaintext}`` (secrets never included)."""
|
|
148
|
+
self._ensure_loaded()
|
|
149
|
+
return _lookup_public_config()
|
|
150
|
+
|
|
151
|
+
# -- ergonomic helpers --------------------------------------------------
|
|
152
|
+
def reveal(self, name: str) -> str:
|
|
153
|
+
"""Return the plaintext value in a single call (decrypts if needed)."""
|
|
154
|
+
value = self._get(name)
|
|
155
|
+
return value.use() if isinstance(value, SecretString) else value
|
|
156
|
+
|
|
157
|
+
async def areveal(self, name: str) -> str:
|
|
158
|
+
"""Async, non-blocking :meth:`reveal` — decrypt + policy fetch run off-loop.
|
|
159
|
+
|
|
160
|
+
The vault-token decrypt makes a network call for repo policy; on an event
|
|
161
|
+
loop use this so FastAPI / asyncio callers don't block::
|
|
162
|
+
|
|
163
|
+
key = await vault.areveal("OPENAI_API_KEY")
|
|
164
|
+
"""
|
|
165
|
+
import asyncio
|
|
166
|
+
|
|
167
|
+
return await asyncio.to_thread(self.reveal, name)
|
|
168
|
+
|
|
169
|
+
def get(self, name: str, default: Any = None) -> Any:
|
|
170
|
+
try:
|
|
171
|
+
return self._get(name)
|
|
172
|
+
except KeyError:
|
|
173
|
+
return default
|
|
174
|
+
|
|
175
|
+
def wipe(self) -> None:
|
|
176
|
+
"""Zero every cached secret from memory."""
|
|
177
|
+
for value in self._cache.values():
|
|
178
|
+
if isinstance(value, SecretString):
|
|
179
|
+
value.wipe()
|
|
180
|
+
self._cache.clear()
|
|
181
|
+
|
|
182
|
+
def __repr__(self) -> str: # never prints values — names only
|
|
183
|
+
return f"<Vault loaded={self._loaded} cached={list(self._cache)}>"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
#: Ready-to-use module singleton, e.g. ``ranbval_sdk.env.reveal("OPENAI_API_KEY")``.
|
|
187
|
+
env = Vault()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def inject(
|
|
191
|
+
*names: str, reveal: bool = False, mode: str | None = None, **aliases: str
|
|
192
|
+
) -> Callable:
|
|
193
|
+
"""Decorator that injects decrypted secrets into a function as keyword arguments.
|
|
194
|
+
|
|
195
|
+
::
|
|
196
|
+
|
|
197
|
+
@inject("OPENAI_API_KEY") # -> kwarg `openai_api_key` (SecretString)
|
|
198
|
+
def main(openai_api_key):
|
|
199
|
+
client = OpenAI(api_key=openai_api_key.use()) # reveal only at the call site
|
|
200
|
+
|
|
201
|
+
**Security-first default:** injects a sealed :class:`SecretString` — it refuses to be
|
|
202
|
+
printed or logged, and wipes itself from memory. Plaintext is never handed out by
|
|
203
|
+
default; call ``.use()`` at the exact point you pass it to an API. ``reveal=True`` is
|
|
204
|
+
an explicit opt-in that injects a guarded ``_ProtectedStr`` (still refuses to print) —
|
|
205
|
+
use it only when a library needs a plain ``str`` and you accept the trade-off.
|
|
206
|
+
|
|
207
|
+
Works on sync **and** async functions. Any argument the caller passes explicitly is
|
|
208
|
+
left untouched.
|
|
209
|
+
"""
|
|
210
|
+
mapping: dict[str, str] = {name.lower(): name for name in names}
|
|
211
|
+
mapping.update(aliases)
|
|
212
|
+
|
|
213
|
+
def _fill(kwargs: dict[str, Any]) -> None:
|
|
214
|
+
_ensure_env_loaded(mode)
|
|
215
|
+
for param, env_var in mapping.items():
|
|
216
|
+
if param not in kwargs:
|
|
217
|
+
kwargs[param] = _resolve(env_var, reveal=reveal)
|
|
218
|
+
|
|
219
|
+
def decorator(fn: Callable) -> Callable:
|
|
220
|
+
if inspect.iscoroutinefunction(fn):
|
|
221
|
+
|
|
222
|
+
@functools.wraps(fn)
|
|
223
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
224
|
+
_fill(kwargs)
|
|
225
|
+
return await fn(*args, **kwargs)
|
|
226
|
+
|
|
227
|
+
return async_wrapper
|
|
228
|
+
|
|
229
|
+
@functools.wraps(fn)
|
|
230
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
231
|
+
_fill(kwargs)
|
|
232
|
+
return fn(*args, **kwargs)
|
|
233
|
+
|
|
234
|
+
return wrapper
|
|
235
|
+
|
|
236
|
+
return decorator
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@contextlib.contextmanager
|
|
240
|
+
def secrets(*, mode: str | None = None, override: bool = False) -> Iterator[Vault]:
|
|
241
|
+
"""Context manager: load config, yield a :class:`Vault`, wipe secrets on exit.
|
|
242
|
+
|
|
243
|
+
::
|
|
244
|
+
|
|
245
|
+
with secrets() as vault:
|
|
246
|
+
client = OpenAI(api_key=vault.reveal("OPENAI_API_KEY"))
|
|
247
|
+
# every decrypted secret is zeroed here
|
|
248
|
+
"""
|
|
249
|
+
vault = Vault(mode=mode, override=override, autoload=True)
|
|
250
|
+
vault._ensure_loaded()
|
|
251
|
+
try:
|
|
252
|
+
yield vault
|
|
253
|
+
finally:
|
|
254
|
+
vault.wipe()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def iter_secrets(*, mode: str | None = None) -> Iterator[tuple[str, SecretString]]:
|
|
258
|
+
"""Yield ``(name, SecretString)`` for every vault token in the environment.
|
|
259
|
+
|
|
260
|
+
::
|
|
261
|
+
|
|
262
|
+
for name, secret in iter_secrets():
|
|
263
|
+
print(name) # names only — values stay sealed
|
|
264
|
+
"""
|
|
265
|
+
_ensure_env_loaded(mode)
|
|
266
|
+
from ranbval_sdk.crypto import decrypt_key
|
|
267
|
+
|
|
268
|
+
for name, raw in os.environ.items():
|
|
269
|
+
if _is_token(raw):
|
|
270
|
+
yield name, decrypt_key(name)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# -- public (unencrypted) configuration --------------------------------------
|
|
274
|
+
def _lookup_public(name: str, default: Any) -> str:
|
|
275
|
+
"""The single place that enforces the public-access policy (env assumed loaded).
|
|
276
|
+
|
|
277
|
+
A key declared ``[secrets]`` — or any ``ranbval.*`` token value — is **never** returned
|
|
278
|
+
here; both raise. This is what guarantees a secret can never leak through a public path,
|
|
279
|
+
no matter which surface (function or ``Vault`` method) the caller used.
|
|
280
|
+
"""
|
|
281
|
+
from ranbval_sdk.config import manifest
|
|
282
|
+
|
|
283
|
+
if manifest.is_secret(name):
|
|
284
|
+
raise RanbvalConfigError(
|
|
285
|
+
f"{name!r} is declared under [secrets]; use decrypt_key({name!r}) instead. "
|
|
286
|
+
"public() only returns plaintext configuration.",
|
|
287
|
+
code="not_a_public_key",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
raw = os.environ.get(name)
|
|
291
|
+
if raw is None:
|
|
292
|
+
if default is not _UNSET:
|
|
293
|
+
return default
|
|
294
|
+
raise MissingKeyError(
|
|
295
|
+
f"{name!r} is not set — did you create it in your .ranbval file?"
|
|
296
|
+
)
|
|
297
|
+
if _is_token(raw):
|
|
298
|
+
raise RanbvalConfigError(
|
|
299
|
+
f"{name!r} holds an encrypted vault token, not a plaintext value. "
|
|
300
|
+
f"Use decrypt_key({name!r}) to decrypt it.",
|
|
301
|
+
code="not_a_public_key",
|
|
302
|
+
)
|
|
303
|
+
return raw
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _lookup_public_config() -> dict[str, str]:
|
|
307
|
+
"""Collect all ``[public]``-declared plaintext values (env assumed loaded)."""
|
|
308
|
+
from ranbval_sdk.config import manifest
|
|
309
|
+
|
|
310
|
+
out: dict[str, str] = {}
|
|
311
|
+
for name in manifest.public_names():
|
|
312
|
+
raw = os.environ.get(name)
|
|
313
|
+
if raw is not None and not _is_token(raw):
|
|
314
|
+
out[name] = raw
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def public(name: str, default: Any = _UNSET, *, mode: str | None = None) -> str:
|
|
319
|
+
"""Return a **plaintext** config value — never decrypts, never a :class:`SecretString`.
|
|
320
|
+
|
|
321
|
+
For values you intentionally keep unencrypted (``DATABASE_URL``, ``CORS_ORIGINS``,
|
|
322
|
+
``PORT``, …). Declare them under a ``[public]`` section in ``.ranbval`` to make the
|
|
323
|
+
intent explicit::
|
|
324
|
+
|
|
325
|
+
# .ranbval
|
|
326
|
+
[public]
|
|
327
|
+
DATABASE_URL=postgresql://localhost/mydb
|
|
328
|
+
CORS_ORIGINS=https://a.com,https://b.com
|
|
329
|
+
|
|
330
|
+
# app.py
|
|
331
|
+
from ranbval_sdk import public
|
|
332
|
+
db = public("DATABASE_URL") # -> plain str
|
|
333
|
+
|
|
334
|
+
Safety rails:
|
|
335
|
+
|
|
336
|
+
- If the key was declared under ``[secrets]``, this raises — use ``decrypt_key`` for it.
|
|
337
|
+
- If the value looks like an encrypted ``ranbval.*`` token, this raises rather than
|
|
338
|
+
handing back ciphertext (you almost certainly meant :func:`~ranbval_sdk.decrypt_key`).
|
|
339
|
+
|
|
340
|
+
``default`` is returned when the key is absent (otherwise :class:`MissingKeyError`).
|
|
341
|
+
"""
|
|
342
|
+
_ensure_env_loaded(mode)
|
|
343
|
+
return _lookup_public(name, default)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def public_config(*, mode: str | None = None) -> dict[str, str]:
|
|
347
|
+
"""Return every key declared under ``[public]`` as a ``{name: plaintext}`` dict.
|
|
348
|
+
|
|
349
|
+
::
|
|
350
|
+
|
|
351
|
+
cfg = public_config()
|
|
352
|
+
app.add_middleware(CORSMiddleware, allow_origins=cfg["CORS_ORIGINS"].split(","))
|
|
353
|
+
"""
|
|
354
|
+
_ensure_env_loaded(mode)
|
|
355
|
+
return _lookup_public_config()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def is_public(name: str) -> bool:
|
|
359
|
+
"""True when *name* was declared under a ``[public]`` section in ``.ranbval``."""
|
|
360
|
+
from ranbval_sdk.config import manifest
|
|
361
|
+
|
|
362
|
+
return manifest.is_public(name)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# The declarative, class-based API (``Secret`` / ``SecretConfig``) lives in
|
|
366
|
+
# ``ranbval_sdk.config.declarative`` — a distinct access style from the imperative
|
|
367
|
+
# ``Vault`` / ``inject`` / ``secrets`` above. Both are re-exported from ``config``.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Declarative secret configuration — the class-based access style.
|
|
2
|
+
|
|
3
|
+
Complements the imperative API in :mod:`ranbval_sdk.config.access` (``Vault`` / ``inject`` /
|
|
4
|
+
``secrets``): here you *declare* a config class whose fields are :class:`Secret` descriptors,
|
|
5
|
+
decrypted lazily and cached per subclass. Both styles resolve through the same
|
|
6
|
+
``config.access._resolve`` so behavior and sealing defaults stay identical.
|
|
7
|
+
|
|
8
|
+
::
|
|
9
|
+
|
|
10
|
+
class Config(SecretConfig):
|
|
11
|
+
openai = Secret("OPENAI_API_KEY")
|
|
12
|
+
stripe = Secret("STRIPE_KEY", reveal=True) # plaintext str
|
|
13
|
+
|
|
14
|
+
Config.openai # -> SecretString (cached on the class)
|
|
15
|
+
Config.stripe # -> plaintext str
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ranbval_sdk.config.access import _ensure_env_loaded, _resolve
|
|
23
|
+
from ranbval_sdk.crypto.secret_string import SecretString
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Secret:
|
|
27
|
+
"""Descriptor for declaring a secret on a config class — decrypted lazily, cached.
|
|
28
|
+
|
|
29
|
+
::
|
|
30
|
+
|
|
31
|
+
class Config(SecretConfig):
|
|
32
|
+
openai = Secret("OPENAI_API_KEY")
|
|
33
|
+
stripe = Secret("STRIPE_KEY", reveal=True) # plaintext str
|
|
34
|
+
|
|
35
|
+
Config.openai # -> SecretString (cached on the class)
|
|
36
|
+
Config.stripe # -> plaintext str
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
__slots__ = ("env_var", "reveal", "attr")
|
|
40
|
+
|
|
41
|
+
def __init__(self, env_var: str, *, reveal: bool = False):
|
|
42
|
+
self.env_var = env_var
|
|
43
|
+
self.reveal = reveal
|
|
44
|
+
self.attr = env_var
|
|
45
|
+
|
|
46
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
47
|
+
self.attr = name
|
|
48
|
+
|
|
49
|
+
def __get__(self, obj: Any, owner: type | None = None) -> Any:
|
|
50
|
+
holder = owner if owner is not None else type(obj)
|
|
51
|
+
_ensure_env_loaded()
|
|
52
|
+
store = holder._secret_cache
|
|
53
|
+
if self.env_var not in store:
|
|
54
|
+
store[self.env_var] = _resolve(self.env_var, reveal=False)
|
|
55
|
+
value = store[self.env_var]
|
|
56
|
+
if self.reveal and isinstance(value, SecretString):
|
|
57
|
+
return value.use()
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SecretConfig:
|
|
62
|
+
"""Base for declarative secret config classes; each subclass gets its own cache."""
|
|
63
|
+
|
|
64
|
+
_secret_cache: dict[str, Any] = {}
|
|
65
|
+
_secret_fields: tuple[str, ...] = ()
|
|
66
|
+
|
|
67
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
68
|
+
super().__init_subclass__(**kwargs)
|
|
69
|
+
cls._secret_cache = {}
|
|
70
|
+
cls._secret_fields = tuple(
|
|
71
|
+
name for name, value in vars(cls).items() if isinstance(value, Secret)
|
|
72
|
+
)
|