lorebase-platform 0.2.3__tar.gz → 0.3.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.
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/PKG-INFO +1 -1
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/pyproject.toml +1 -1
- lorebase_platform-0.3.0/src/lorebase_platform/__init__.py +1 -0
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/src/lorebase_platform/secrets.py +28 -1
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/tests/test_secrets.py +43 -0
- lorebase_platform-0.2.3/src/lorebase_platform/__init__.py +0 -1
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/.gitignore +0 -0
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/src/lorebase_platform/logging.py +0 -0
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/src/lorebase_platform/settings.py +0 -0
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/src/lorebase_platform/telemetry.py +0 -0
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/tests/conftest.py +0 -0
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/tests/test_logging.py +0 -0
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/tests/test_settings.py +0 -0
- {lorebase_platform-0.2.3 → lorebase_platform-0.3.0}/tests/test_telemetry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lorebase-platform"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Lorebase platform: settings, secrets, logging and telemetry"
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
dependencies = ["pydantic>=2.7", "pydantic-settings>=2.3", "pyyaml>=6.0", "opentelemetry-api>=1.25", "opentelemetry-sdk>=1.25", "opentelemetry-exporter-otlp-proto-grpc>=1.25"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -6,6 +6,9 @@ A secret setting holds either a literal value or a reference resolved at load ti
|
|
|
6
6
|
- `secret://file/PATH`: content of the file at `PATH` (trailing newline removed). Use a double
|
|
7
7
|
slash for an absolute path, for example `secret://file//run/secrets/db_password`.
|
|
8
8
|
|
|
9
|
+
Other providers (a vault, a cloud secret manager) are added with `register_secret_provider`
|
|
10
|
+
before the settings are loaded; `env` and `file` cannot be replaced.
|
|
11
|
+
|
|
9
12
|
Resolved values are typed `SecretStr` and are registered so that the logging redaction filter
|
|
10
13
|
can mask them wherever they appear.
|
|
11
14
|
"""
|
|
@@ -22,8 +25,13 @@ from typing import Annotated, Any
|
|
|
22
25
|
from pydantic import BeforeValidator, SecretStr
|
|
23
26
|
|
|
24
27
|
REFERENCE_PREFIX = "secret://"
|
|
28
|
+
_PROVIDER_NAME = re.compile(r"^[a-z0-9_-]+$")
|
|
25
29
|
_REFERENCE = re.compile(r"^secret://(?P<provider>[a-z0-9_-]+)/(?P<target>.+)$")
|
|
26
30
|
|
|
31
|
+
SecretProvider = Callable[[str], str]
|
|
32
|
+
"""Resolves the target of a reference (the part after `secret://<name>/`) to its value; raises
|
|
33
|
+
`SecretResolutionError`, with a message holding no secret value, when it cannot."""
|
|
34
|
+
|
|
27
35
|
|
|
28
36
|
class SecretResolutionError(ValueError):
|
|
29
37
|
"""A secret reference cannot be resolved. The message never contains a secret value."""
|
|
@@ -44,12 +52,31 @@ def _from_file(path: str) -> str:
|
|
|
44
52
|
return content.removesuffix("\n").removesuffix("\r")
|
|
45
53
|
|
|
46
54
|
|
|
47
|
-
_PROVIDERS: dict[str,
|
|
55
|
+
_PROVIDERS: dict[str, SecretProvider] = {"env": _from_env, "file": _from_file}
|
|
56
|
+
_BUILT_IN = frozenset(_PROVIDERS)
|
|
48
57
|
|
|
49
58
|
_known_values: set[str] = set()
|
|
50
59
|
_lock = threading.Lock()
|
|
51
60
|
|
|
52
61
|
|
|
62
|
+
def register_secret_provider(name: str, provider: SecretProvider, *, replace: bool = False) -> None:
|
|
63
|
+
"""Resolve `secret://<name>/TARGET` references with `provider`.
|
|
64
|
+
|
|
65
|
+
`name` takes lowercase letters, digits, `_` and `-`. The built-in `env` and `file` cannot
|
|
66
|
+
be replaced; registering a name twice raises `ValueError` unless `replace` is true.
|
|
67
|
+
"""
|
|
68
|
+
if not _PROVIDER_NAME.match(name):
|
|
69
|
+
raise ValueError(f"invalid secret provider name {name!r}; use [a-z0-9_-]+")
|
|
70
|
+
if not callable(provider):
|
|
71
|
+
raise TypeError("a secret provider must be callable")
|
|
72
|
+
if name in _BUILT_IN:
|
|
73
|
+
raise ValueError(f"the built-in secret provider {name!r} cannot be replaced")
|
|
74
|
+
with _lock:
|
|
75
|
+
if name in _PROVIDERS and not replace:
|
|
76
|
+
raise ValueError(f"secret provider {name!r} is already registered")
|
|
77
|
+
_PROVIDERS[name] = provider
|
|
78
|
+
|
|
79
|
+
|
|
53
80
|
def register_secret_value(value: str) -> None:
|
|
54
81
|
"""Remember a secret value so log redaction can mask it."""
|
|
55
82
|
if value:
|
|
@@ -5,9 +5,11 @@ from pathlib import Path
|
|
|
5
5
|
import pytest
|
|
6
6
|
from pydantic import SecretStr
|
|
7
7
|
|
|
8
|
+
from lorebase_platform import secrets
|
|
8
9
|
from lorebase_platform.secrets import (
|
|
9
10
|
SecretResolutionError,
|
|
10
11
|
SecretValue,
|
|
12
|
+
register_secret_provider,
|
|
11
13
|
registered_secret_values,
|
|
12
14
|
resolve_secret,
|
|
13
15
|
)
|
|
@@ -101,3 +103,44 @@ def test_errors_never_contain_secret_values(monkeypatch: pytest.MonkeyPatch) ->
|
|
|
101
103
|
with pytest.raises(ConfigError) as caught:
|
|
102
104
|
load_settings()
|
|
103
105
|
assert "super-secret-literal" not in str(caught.value)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.fixture
|
|
109
|
+
def providers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
110
|
+
"""Providers registered by a test are forgotten afterwards."""
|
|
111
|
+
monkeypatch.setattr(secrets, "_PROVIDERS", dict(secrets._PROVIDERS))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.usefixtures("providers")
|
|
115
|
+
def test_a_registered_provider_resolves_its_references() -> None:
|
|
116
|
+
vault = {"db/password": "vault-s3cr3t"}
|
|
117
|
+
|
|
118
|
+
def lookup(target: str) -> str:
|
|
119
|
+
if target not in vault:
|
|
120
|
+
raise SecretResolutionError(f"no secret at {target!r}")
|
|
121
|
+
return vault[target]
|
|
122
|
+
|
|
123
|
+
register_secret_provider("vault", lookup)
|
|
124
|
+
assert resolve_secret("secret://vault/db/password") == "vault-s3cr3t"
|
|
125
|
+
assert "vault-s3cr3t" in registered_secret_values()
|
|
126
|
+
with pytest.raises(SecretResolutionError, match="no secret at 'missing'"):
|
|
127
|
+
resolve_secret("secret://vault/missing")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.usefixtures("providers")
|
|
131
|
+
def test_providers_are_not_replaced_silently() -> None:
|
|
132
|
+
register_secret_provider("vault", lambda target: "first")
|
|
133
|
+
with pytest.raises(ValueError, match="already registered"):
|
|
134
|
+
register_secret_provider("vault", lambda target: "second")
|
|
135
|
+
register_secret_provider("vault", lambda target: "second", replace=True)
|
|
136
|
+
assert resolve_secret("secret://vault/x") == "second"
|
|
137
|
+
for built_in in ("env", "file"):
|
|
138
|
+
with pytest.raises(ValueError, match="cannot be replaced"):
|
|
139
|
+
register_secret_provider(built_in, lambda target: "x", replace=True)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.mark.usefixtures("providers")
|
|
143
|
+
@pytest.mark.parametrize("name", ["", "Vault", "my vault", "a/b", "vault:"])
|
|
144
|
+
def test_provider_names_must_be_usable_in_references(name: str) -> None:
|
|
145
|
+
with pytest.raises(ValueError, match="invalid secret provider name"):
|
|
146
|
+
register_secret_provider(name, lambda target: "x")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.3"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|