simple-module-settings 0.0.32__tar.gz → 0.0.34__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.
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/PKG-INFO +4 -4
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/package.json +3 -1
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/pyproject.toml +4 -4
- simple_module_settings-0.0.34/settings/_field_meta.py +81 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/_module_settings.py +71 -106
- simple_module_settings-0.0.34/settings/_module_settings_props.py +56 -0
- simple_module_settings-0.0.34/settings/_row_masking.py +68 -0
- simple_module_settings-0.0.34/settings/_secrets.py +97 -0
- simple_module_settings-0.0.34/settings/browse_query.py +67 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/constants.py +29 -1
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/endpoints/module_api.py +47 -8
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/endpoints/views.py +64 -44
- simple_module_settings-0.0.34/settings/known_keys.py +86 -0
- simple_module_settings-0.0.34/settings/locales/en.json +141 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/module.py +32 -1
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/module_registry.py +19 -2
- simple_module_settings-0.0.34/settings/pages/Browse.tsx +174 -0
- simple_module_settings-0.0.34/settings/pages/Create.tsx +163 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/pages/Edit.tsx +6 -22
- simple_module_settings-0.0.34/settings/pages/ModulesEdit.tsx +147 -0
- simple_module_settings-0.0.34/settings/pages/components/FieldInput.tsx +152 -0
- simple_module_settings-0.0.34/settings/pages/components/FieldSource.tsx +77 -0
- simple_module_settings-0.0.34/settings/pages/components/KeyField.tsx +142 -0
- simple_module_settings-0.0.34/settings/pages/components/ModuleFieldRow.tsx +69 -0
- simple_module_settings-0.0.34/settings/pages/components/ModuleForm.tsx +179 -0
- simple_module_settings-0.0.34/settings/pages/components/ResolvedValue.tsx +89 -0
- simple_module_settings-0.0.34/settings/pages/components/ScopeTabs.tsx +37 -0
- simple_module_settings-0.0.34/settings/pages/components/StoreCards.tsx +93 -0
- simple_module_settings-0.0.34/settings/pages/components/StoreTable.tsx +117 -0
- simple_module_settings-0.0.34/settings/pages/components/TestConnectionButton.tsx +122 -0
- simple_module_settings-0.0.34/settings/pages/components/ValueInput.tsx +87 -0
- simple_module_settings-0.0.34/settings/pages/components/last-test.ts +48 -0
- simple_module_settings-0.0.34/settings/pages/routes.ts +10 -0
- simple_module_settings-0.0.34/settings/pages/types.ts +25 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/registration.py +8 -2
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/reload.py +7 -2
- simple_module_settings-0.0.34/settings/service.py +270 -0
- simple_module_settings-0.0.34/settings/settings.py +15 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/store.py +10 -3
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_cli_import.py +8 -0
- simple_module_settings-0.0.34/tests/test_known_keys_meta.py +211 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_module_api.py +22 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_module_settings.py +1 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_module_settings_render.py +55 -3
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_module_settings_serialize.py +1 -1
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_field_sources.py +55 -4
- simple_module_settings-0.0.34/tests/test_settings_secret_masking.py +186 -0
- simple_module_settings-0.0.34/tests/test_settings_secret_urls.py +162 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_view_authz.py +2 -2
- simple_module_settings-0.0.34/tests/test_store_filters.py +226 -0
- simple_module_settings-0.0.34/tests/test_store_secret_masking.py +231 -0
- simple_module_settings-0.0.34/tests/test_testable_checks.py +65 -0
- simple_module_settings-0.0.34/tests-js/ModuleForm.test.tsx +237 -0
- simple_module_settings-0.0.34/tests-js/ScopeTabs.test.tsx +67 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tsconfig.json +1 -1
- simple_module_settings-0.0.32/settings/locales/en.json +0 -82
- simple_module_settings-0.0.32/settings/pages/Browse.tsx +0 -157
- simple_module_settings-0.0.32/settings/pages/Create.tsx +0 -164
- simple_module_settings-0.0.32/settings/pages/ModulesEdit.tsx +0 -113
- simple_module_settings-0.0.32/settings/pages/components/FieldInput.tsx +0 -102
- simple_module_settings-0.0.32/settings/pages/components/FieldSource.tsx +0 -59
- simple_module_settings-0.0.32/settings/pages/components/KeyField.tsx +0 -104
- simple_module_settings-0.0.32/settings/pages/components/ModuleForm.tsx +0 -167
- simple_module_settings-0.0.32/settings/pages/components/TestConnectionButton.tsx +0 -70
- simple_module_settings-0.0.32/settings/pages/components/ValueInput.tsx +0 -92
- simple_module_settings-0.0.32/settings/pages/routes.ts +0 -10
- simple_module_settings-0.0.32/settings/service.py +0 -162
- simple_module_settings-0.0.32/settings/settings.py +0 -11
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/.gitignore +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/LICENSE +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/README.md +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/__init__.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/cli.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/contracts/__init__.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/contracts/accessor.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/contracts/events.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/contracts/registry.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/contracts/schemas.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/deps.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/endpoints/__init__.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/endpoints/api.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/env_vars.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/hydrate.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/models.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/py.typed +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/services.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_concurrent_overrides.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_hydrate.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_module_registry.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_registration.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_reload.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_accessor.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_api.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_events.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_module.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_schemas.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_service.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_settings
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.34
|
|
4
4
|
Summary: Runtime settings UI — modules plug their own settings panels into a shared admin view
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist: simple-module-core==0.0.
|
|
25
|
-
Requires-Dist: simple-module-db==0.0.
|
|
26
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
24
|
+
Requires-Dist: simple-module-core==0.0.34
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.34
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.34
|
|
27
27
|
Requires-Dist: typer>=0.12
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_settings"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.34"
|
|
4
4
|
description = "Runtime settings UI — modules plug their own settings panels into a shared admin view"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,9 +21,9 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
24
|
+
"simple_module_core==0.0.34",
|
|
25
|
+
"simple_module_db==0.0.34",
|
|
26
|
+
"simple_module_hosting==0.0.34",
|
|
27
27
|
"typer>=0.12",
|
|
28
28
|
]
|
|
29
29
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Pure metadata readers for a single pydantic settings field.
|
|
2
|
+
|
|
3
|
+
Split out of ``_module_settings`` so that file keeps one job — walking the app
|
|
4
|
+
for settings classes — while the fiddly "what did pydantic actually record for
|
|
5
|
+
this field?" questions live together and are testable without a FastAPI app.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic_settings import BaseSettings
|
|
14
|
+
|
|
15
|
+
# ``^(console|smtp)$`` and nothing more adventurous: an alternation of literals
|
|
16
|
+
# anchored at both ends is the only pattern shape that maps cleanly onto a
|
|
17
|
+
# select. Anything else (character classes, quantifiers, sub-groups) stays a
|
|
18
|
+
# free-text input rather than becoming a list that silently omits valid values.
|
|
19
|
+
_ENUM_PATTERN = re.compile(r"^\^\(([^()|]+(?:\|[^()|]+)+)\)\$$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_default(info) -> Any:
|
|
23
|
+
"""Return the effective default, handling ``default_factory`` fields.
|
|
24
|
+
|
|
25
|
+
Pydantic sets ``info.default`` to ``PydanticUndefined`` when
|
|
26
|
+
``default_factory`` is used. We call the factory to get the concrete
|
|
27
|
+
default so the settings UI can serialize it.
|
|
28
|
+
"""
|
|
29
|
+
from pydantic_core import PydanticUndefined
|
|
30
|
+
|
|
31
|
+
if info.default is not PydanticUndefined:
|
|
32
|
+
return info.default
|
|
33
|
+
if info.default_factory is not None:
|
|
34
|
+
return info.default_factory()
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def env_readable_var(settings: BaseSettings, name: str) -> str | None:
|
|
39
|
+
"""Env var pydantic would actually read for ``name``, or ``None``.
|
|
40
|
+
|
|
41
|
+
``env_var`` on the view is a *label* — the ``SM_<PACKAGE>_<FIELD>`` name the
|
|
42
|
+
``smpy settings import-from-env`` CLI looks for, kept from before settings
|
|
43
|
+
moved into the DB. It is not evidence that pydantic reads it: the bundled
|
|
44
|
+
module settings classes subclass ``DbBackedSettings``, which drops the env
|
|
45
|
+
source entirely, so ``SM_FILE_STORAGE_BACKEND`` has no effect on
|
|
46
|
+
``FileStorageSettings()``. (Until GH #283 those classes subclassed
|
|
47
|
+
``BaseSettings`` and merely omitted ``env_prefix``, which left pydantic
|
|
48
|
+
reading each field from its *bare* name instead of not at all.) Deriving
|
|
49
|
+
env-readability from the class's own ``env_prefix`` keeps the "From
|
|
50
|
+
environment" badge honest, and works as-is for the classes that do declare
|
|
51
|
+
one — the host's ``Settings`` (``SM_``), ``BackgroundTasksSettings``
|
|
52
|
+
(``SM_BG_TASKS_``), and every module built from the scaffold, whose
|
|
53
|
+
template ships ``env_prefix="SM_<PACKAGE>_"``.
|
|
54
|
+
"""
|
|
55
|
+
env_prefix = str(type(settings).model_config.get("env_prefix") or "")
|
|
56
|
+
if not env_prefix:
|
|
57
|
+
return None
|
|
58
|
+
return f"{env_prefix}{name.upper()}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def choices_for(info) -> list[str] | None:
|
|
62
|
+
"""The closed set of values this field accepts, or ``None`` if open.
|
|
63
|
+
|
|
64
|
+
Read from an explicit ``json_schema_extra={"choices": [...]}`` first, then
|
|
65
|
+
inferred from a simple alternation ``pattern``. A field constrained to
|
|
66
|
+
``console`` or ``smtp`` renders as a select; without this it is a text box
|
|
67
|
+
whose only feedback on a typo is a 422 after Save.
|
|
68
|
+
"""
|
|
69
|
+
extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
|
70
|
+
declared = extra.get("choices")
|
|
71
|
+
if isinstance(declared, (list, tuple)) and declared:
|
|
72
|
+
return [str(choice) for choice in declared]
|
|
73
|
+
|
|
74
|
+
for constraint in info.metadata:
|
|
75
|
+
pattern = getattr(constraint, "pattern", None)
|
|
76
|
+
if not isinstance(pattern, str):
|
|
77
|
+
continue
|
|
78
|
+
match = _ENUM_PATTERN.match(pattern)
|
|
79
|
+
if match:
|
|
80
|
+
return match.group(1).split("|")
|
|
81
|
+
return None
|
{simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/_module_settings.py
RENAMED
|
@@ -7,31 +7,28 @@ Each module stores a services dataclass on ``app.state.<package>`` whose
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
-
import re
|
|
11
10
|
from dataclasses import dataclass
|
|
12
11
|
from typing import Any
|
|
13
12
|
|
|
14
13
|
from fastapi import FastAPI
|
|
15
|
-
from fastapi.encoders import jsonable_encoder
|
|
16
14
|
from pydantic_settings import BaseSettings
|
|
17
15
|
|
|
16
|
+
from settings._field_meta import choices_for, env_readable_var, resolve_default
|
|
17
|
+
|
|
18
|
+
# Re-exported: callers reach for ``SECRET_MASK`` / ``is_secret_field`` here.
|
|
19
|
+
from settings._secrets import ( # noqa: F401
|
|
20
|
+
_NEVER_SECRET_TYPES,
|
|
21
|
+
SECRET_MASK,
|
|
22
|
+
conceals_secret,
|
|
23
|
+
embeds_credential,
|
|
24
|
+
is_named_secret,
|
|
25
|
+
is_secret_field,
|
|
26
|
+
mask,
|
|
27
|
+
)
|
|
18
28
|
from settings.env_vars import env_prefix_for
|
|
19
29
|
from settings.hydrate import value_type_for_field
|
|
20
30
|
from settings.service import SettingService
|
|
21
31
|
|
|
22
|
-
# We intentionally DON'T match the bare word "token" (would mask
|
|
23
|
-
# `verification_token_lifetime_seconds` — just an int) or "key" alone (would
|
|
24
|
-
# mask `s3_bucket_key_prefix`). Only fragments that actually indicate material.
|
|
25
|
-
_SECRET_PATTERNS = re.compile(
|
|
26
|
-
r"(password|secret|api[_-]?key|private[_-]?key|token[_-]?secret)", re.I
|
|
27
|
-
)
|
|
28
|
-
SECRET_MASK = "••••••••"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def is_secret_field(name: str) -> bool:
|
|
32
|
-
"""True if a field name suggests it holds credential material."""
|
|
33
|
-
return bool(_SECRET_PATTERNS.search(name))
|
|
34
|
-
|
|
35
32
|
|
|
36
33
|
@dataclass(frozen=True, slots=True)
|
|
37
34
|
class ModuleSettingField:
|
|
@@ -58,6 +55,22 @@ class ModuleSettingField:
|
|
|
58
55
|
"""
|
|
59
56
|
db_override: bool = False
|
|
60
57
|
"""A stored setting overrides this field."""
|
|
58
|
+
env_readable: bool = False
|
|
59
|
+
"""The declaring class reads env at all, i.e. it declares an ``env_prefix``.
|
|
60
|
+
|
|
61
|
+
:attr:`env_set` collapses two very different answers into one ``False``:
|
|
62
|
+
"there is no env fallback for this field" and "there is one and nobody set
|
|
63
|
+
it". The Resolved value panel has to tell them apart, or it labels a row
|
|
64
|
+
"env fallback" for a class that will never look at the environment.
|
|
65
|
+
"""
|
|
66
|
+
env_value: str | None = None
|
|
67
|
+
"""The env var's current contents, masked for secrets, or ``None``.
|
|
68
|
+
|
|
69
|
+
Only populated when the field is genuinely env-readable *and* set — it is
|
|
70
|
+
the fallback the panel shows, not a second copy of the live value.
|
|
71
|
+
"""
|
|
72
|
+
choices: list[str] | None = None
|
|
73
|
+
"""Closed set of accepted values, or ``None`` when the field is free text."""
|
|
61
74
|
|
|
62
75
|
@property
|
|
63
76
|
def source(self) -> str:
|
|
@@ -84,12 +97,9 @@ class ModuleSettingsView:
|
|
|
84
97
|
env_prefix: str
|
|
85
98
|
class_name: str
|
|
86
99
|
fields: list[ModuleSettingField]
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if value in (None, "", [], {}):
|
|
91
|
-
return value
|
|
92
|
-
return SECRET_MASK
|
|
100
|
+
manage_url: str | None = None
|
|
101
|
+
"""The module's own management page. When set, the generic editor renders
|
|
102
|
+
a link there instead of a second editor for the same fields."""
|
|
93
103
|
|
|
94
104
|
|
|
95
105
|
def _package_of(mod: Any) -> str:
|
|
@@ -111,42 +121,6 @@ def _extract_settings(app: FastAPI, package: str) -> BaseSettings | None:
|
|
|
111
121
|
return inner if isinstance(inner, BaseSettings) else None
|
|
112
122
|
|
|
113
123
|
|
|
114
|
-
def _resolve_default(info) -> Any:
|
|
115
|
-
"""Return the effective default, handling ``default_factory`` fields.
|
|
116
|
-
|
|
117
|
-
Pydantic sets ``info.default`` to ``PydanticUndefined`` when
|
|
118
|
-
``default_factory`` is used. We call the factory to get the concrete
|
|
119
|
-
default so the settings UI can serialize it.
|
|
120
|
-
"""
|
|
121
|
-
from pydantic_core import PydanticUndefined
|
|
122
|
-
|
|
123
|
-
if info.default is not PydanticUndefined:
|
|
124
|
-
return info.default
|
|
125
|
-
if info.default_factory is not None:
|
|
126
|
-
return info.default_factory()
|
|
127
|
-
return None
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def _env_readable_var(settings: BaseSettings, name: str) -> str | None:
|
|
131
|
-
"""Env var pydantic would actually read for ``name``, or ``None``.
|
|
132
|
-
|
|
133
|
-
``env_var`` on the view is a *label* — the ``SM_<PACKAGE>_<FIELD>`` name the
|
|
134
|
-
``smpy settings import-from-env`` CLI looks for, kept from before settings
|
|
135
|
-
moved into the DB. It is not evidence that pydantic reads it: the bundled
|
|
136
|
-
module settings classes declare ``SettingsConfigDict(extra="ignore")`` with
|
|
137
|
-
no ``env_prefix``, so ``SM_FILE_STORAGE_BACKEND`` has no effect on
|
|
138
|
-
``FileStorageSettings()``. Deriving env-readability from the class's own
|
|
139
|
-
``env_prefix`` keeps the "From environment" badge honest, and works as-is
|
|
140
|
-
for the classes that do declare one — the host's ``Settings`` (``SM_``) and
|
|
141
|
-
every module built from the scaffold, whose template ships
|
|
142
|
-
``env_prefix="SM_<PACKAGE>_"``.
|
|
143
|
-
"""
|
|
144
|
-
env_prefix = str(type(settings).model_config.get("env_prefix") or "")
|
|
145
|
-
if not env_prefix:
|
|
146
|
-
return None
|
|
147
|
-
return f"{env_prefix}{name.upper()}"
|
|
148
|
-
|
|
149
|
-
|
|
150
124
|
def _field_view(
|
|
151
125
|
name: str,
|
|
152
126
|
settings: BaseSettings,
|
|
@@ -156,23 +130,42 @@ def _field_view(
|
|
|
156
130
|
cls = type(settings)
|
|
157
131
|
info = cls.model_fields[name]
|
|
158
132
|
raw_value = getattr(settings, name)
|
|
159
|
-
|
|
133
|
+
value_type = value_type_for_field(cls, name)
|
|
134
|
+
named_secret = is_named_secret(name, value_type)
|
|
160
135
|
extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
|
161
|
-
default =
|
|
136
|
+
default = resolve_default(info)
|
|
162
137
|
env_var = f"{prefix}{name.upper()}"
|
|
163
|
-
live_env_var =
|
|
138
|
+
live_env_var = env_readable_var(settings, name)
|
|
139
|
+
env_set = live_env_var is not None and live_env_var in os.environ
|
|
140
|
+
# ``live_env_var`` is not None whenever env_set is True, but the narrowing
|
|
141
|
+
# is spelled out so a future edit cannot turn this into ``os.environ[None]``.
|
|
142
|
+
raw_env = os.environ[live_env_var] if env_set and live_env_var is not None else None
|
|
143
|
+
|
|
144
|
+
def hidden(value: Any) -> bool:
|
|
145
|
+
return conceals_secret(name, value, value_type)
|
|
146
|
+
|
|
147
|
+
# ``is_secret`` drives the editor's input type and the "reveal" affordance,
|
|
148
|
+
# so it has to be true when *any* of the three readings is being hidden —
|
|
149
|
+
# otherwise the form offers to edit a value it is showing as dots.
|
|
150
|
+
secret = named_secret or any(embeds_credential(v) for v in (raw_value, default, raw_env))
|
|
164
151
|
return ModuleSettingField(
|
|
165
152
|
name=name,
|
|
166
153
|
env_var=env_var,
|
|
167
|
-
value=
|
|
168
|
-
default=
|
|
154
|
+
value=mask(raw_value) if hidden(raw_value) else raw_value,
|
|
155
|
+
default=mask(default) if hidden(default) else default,
|
|
169
156
|
description=info.description or "",
|
|
170
157
|
is_secret=secret,
|
|
171
|
-
type=
|
|
158
|
+
type=value_type,
|
|
172
159
|
requires_restart=bool(extra.get("requires_restart", False)),
|
|
173
160
|
group=extra.get("group"),
|
|
174
|
-
env_set=
|
|
161
|
+
env_set=env_set,
|
|
175
162
|
db_override=name in overridden,
|
|
163
|
+
env_readable=live_env_var is not None,
|
|
164
|
+
# Masked on the same rule as ``value`` and ``default``: a secret stays
|
|
165
|
+
# hidden, everything else is shown. Masking unconditionally turned the
|
|
166
|
+
# Resolved value panel's env row into a row of dots for every key.
|
|
167
|
+
env_value=(mask(raw_env) if hidden(raw_env) else raw_env) if raw_env is not None else None,
|
|
168
|
+
choices=choices_for(info),
|
|
176
169
|
)
|
|
177
170
|
|
|
178
171
|
|
|
@@ -194,16 +187,22 @@ def collect_module_settings(
|
|
|
194
187
|
views: list[ModuleSettingsView] = []
|
|
195
188
|
seen: set[str] = set()
|
|
196
189
|
|
|
190
|
+
settings_services = getattr(app.state, "settings", None)
|
|
191
|
+
registry = getattr(settings_services, "module_registry", None)
|
|
192
|
+
|
|
193
|
+
def _manage_url(package: str) -> str | None:
|
|
194
|
+
return registry.manage_url(package) if registry is not None else None
|
|
195
|
+
|
|
197
196
|
for mod in getattr(app.state.sm, "modules", ()):
|
|
198
197
|
package = _package_of(mod)
|
|
199
198
|
settings = _extract_settings(app, package)
|
|
200
199
|
if settings is None:
|
|
201
200
|
continue
|
|
202
|
-
views.append(
|
|
201
|
+
views.append(
|
|
202
|
+
_build_view(mod.meta.name, package, settings, by_package, _manage_url(package))
|
|
203
|
+
)
|
|
203
204
|
seen.add(package)
|
|
204
205
|
|
|
205
|
-
settings_services = getattr(app.state, "settings", None)
|
|
206
|
-
registry = getattr(settings_services, "module_registry", None)
|
|
207
206
|
if registry is not None:
|
|
208
207
|
for package in registry.all_packages():
|
|
209
208
|
if package in seen:
|
|
@@ -211,7 +210,9 @@ def collect_module_settings(
|
|
|
211
210
|
settings = _extract_settings(app, package)
|
|
212
211
|
if settings is None:
|
|
213
212
|
continue
|
|
214
|
-
views.append(
|
|
213
|
+
views.append(
|
|
214
|
+
_build_view(package.title(), package, settings, by_package, _manage_url(package))
|
|
215
|
+
)
|
|
215
216
|
seen.add(package)
|
|
216
217
|
|
|
217
218
|
views.sort(key=lambda v: v.module_name)
|
|
@@ -223,6 +224,7 @@ def _build_view(
|
|
|
223
224
|
package: str,
|
|
224
225
|
settings: BaseSettings,
|
|
225
226
|
overrides: dict[str, frozenset[str]] | None = None,
|
|
227
|
+
manage_url: str | None = None,
|
|
226
228
|
) -> ModuleSettingsView:
|
|
227
229
|
prefix = env_prefix_for(package)
|
|
228
230
|
overridden = (overrides or {}).get(package, frozenset())
|
|
@@ -235,6 +237,7 @@ def _build_view(
|
|
|
235
237
|
env_prefix=prefix,
|
|
236
238
|
class_name=type(settings).__name__,
|
|
237
239
|
fields=fields,
|
|
240
|
+
manage_url=manage_url,
|
|
238
241
|
)
|
|
239
242
|
|
|
240
243
|
|
|
@@ -249,41 +252,3 @@ async def overrides_by_package(service: SettingService) -> dict[str, frozenset[s
|
|
|
249
252
|
from settings.store import SettingsStore
|
|
250
253
|
|
|
251
254
|
return await SettingsStore(service).all_override_fields()
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
|
|
255
|
-
"""Convert dataclass views to plain dicts for Inertia props.
|
|
256
|
-
|
|
257
|
-
Field values arrive as whatever type the module declared — pydantic has
|
|
258
|
-
already coerced ``media_root: Path`` to a ``PosixPath``, ``timeout:
|
|
259
|
-
timedelta`` to a ``timedelta`` — and this screen reflects every installed
|
|
260
|
-
module's settings, so the set of types is open-ended by design. They are
|
|
261
|
-
encoded here rather than handed on as-is: this is the boundary where a
|
|
262
|
-
settings object stops being Python and becomes a prop.
|
|
263
|
-
"""
|
|
264
|
-
return [
|
|
265
|
-
{
|
|
266
|
-
"module_name": v.module_name,
|
|
267
|
-
"package": v.package,
|
|
268
|
-
"env_prefix": v.env_prefix,
|
|
269
|
-
"class_name": v.class_name,
|
|
270
|
-
"fields": [
|
|
271
|
-
{
|
|
272
|
-
"name": f.name,
|
|
273
|
-
"env_var": f.env_var,
|
|
274
|
-
"value": jsonable_encoder(f.value),
|
|
275
|
-
"default": jsonable_encoder(f.default),
|
|
276
|
-
"description": f.description,
|
|
277
|
-
"is_secret": f.is_secret,
|
|
278
|
-
"type": f.type,
|
|
279
|
-
"requires_restart": f.requires_restart,
|
|
280
|
-
"group": f.group,
|
|
281
|
-
"env_set": f.env_set,
|
|
282
|
-
"db_override": f.db_override,
|
|
283
|
-
"source": f.source,
|
|
284
|
-
}
|
|
285
|
-
for f in v.fields
|
|
286
|
-
],
|
|
287
|
-
}
|
|
288
|
-
for v in views
|
|
289
|
-
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Serialize module-settings views into Inertia props.
|
|
2
|
+
|
|
3
|
+
Split from ``_module_settings`` (collection) so each file keeps one
|
|
4
|
+
responsibility: that one discovers and shapes the views, this one is the
|
|
5
|
+
boundary where a settings object stops being Python and becomes a prop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastapi.encoders import jsonable_encoder
|
|
13
|
+
|
|
14
|
+
from settings._module_settings import ModuleSettingsView
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
|
|
18
|
+
"""Convert dataclass views to plain dicts for Inertia props.
|
|
19
|
+
|
|
20
|
+
Field values arrive as whatever type the module declared — pydantic has
|
|
21
|
+
already coerced ``media_root: Path`` to a ``PosixPath``, ``timeout:
|
|
22
|
+
timedelta`` to a ``timedelta`` — and this screen reflects every installed
|
|
23
|
+
module's settings, so the set of types is open-ended by design. They are
|
|
24
|
+
encoded here rather than handed on as-is.
|
|
25
|
+
"""
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
"module_name": v.module_name,
|
|
29
|
+
"package": v.package,
|
|
30
|
+
"env_prefix": v.env_prefix,
|
|
31
|
+
"class_name": v.class_name,
|
|
32
|
+
"manage_url": v.manage_url,
|
|
33
|
+
"fields": [
|
|
34
|
+
{
|
|
35
|
+
"name": f.name,
|
|
36
|
+
"env_var": f.env_var,
|
|
37
|
+
"value": jsonable_encoder(f.value),
|
|
38
|
+
"default": jsonable_encoder(f.default),
|
|
39
|
+
"description": f.description,
|
|
40
|
+
"is_secret": f.is_secret,
|
|
41
|
+
"type": f.type,
|
|
42
|
+
"requires_restart": f.requires_restart,
|
|
43
|
+
"group": f.group,
|
|
44
|
+
"env_set": f.env_set,
|
|
45
|
+
"env_readable": f.env_readable,
|
|
46
|
+
"db_override": f.db_override,
|
|
47
|
+
"source": f.source,
|
|
48
|
+
# Turns a pattern-constrained string into a select rather
|
|
49
|
+
# than a text box whose only feedback on a typo is a 422.
|
|
50
|
+
"choices": f.choices,
|
|
51
|
+
}
|
|
52
|
+
for f in v.fields
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
for v in views
|
|
56
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Whether a stored row's value may be shown, and how the mask is honoured.
|
|
2
|
+
|
|
3
|
+
Split out of ``service`` so the "is this a secret, and is this write the mask
|
|
4
|
+
being echoed back?" question lives in one file, next to the field-level rules in
|
|
5
|
+
``_secrets`` that it delegates to. ``service`` imports these; nothing else
|
|
6
|
+
should need to, because the point of funnelling every read through ``out`` is
|
|
7
|
+
that there is no second way to serialize a row.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from settings._secrets import conceals_secret
|
|
13
|
+
from settings.constants import SENSITIVE_KEYS, SENSITIVE_PLACEHOLDER
|
|
14
|
+
from settings.contracts.schemas import SettingOut
|
|
15
|
+
from settings.models import Setting
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_masked(entity: Setting) -> bool:
|
|
19
|
+
"""Whether this row's stored value must not leave the service.
|
|
20
|
+
|
|
21
|
+
:data:`SENSITIVE_KEYS` names the one key the hosting layer owns
|
|
22
|
+
(``host.secret_key``). Everything else is judged by the same name/value
|
|
23
|
+
rule the module editor uses, because the store is the *same data* seen
|
|
24
|
+
through a different screen: an override named ``users.smtp_password``, or
|
|
25
|
+
any override holding a ``postgresql://user:pw@host/db``, was rendered in
|
|
26
|
+
clear text in the browse table and pre-filled into the edit form purely
|
|
27
|
+
because the allowlist had a single entry in it.
|
|
28
|
+
"""
|
|
29
|
+
return entity.key in SENSITIVE_KEYS or conceals_secret(
|
|
30
|
+
entity.key, entity.value, entity.value_type
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def out(entity: Setting) -> SettingOut:
|
|
35
|
+
"""Serialize a row, masking values that must not leave the service.
|
|
36
|
+
|
|
37
|
+
Every read path funnels through here so a secret cannot be read back by
|
|
38
|
+
listing it, resolving it, or fetching it by id.
|
|
39
|
+
"""
|
|
40
|
+
out = SettingOut.model_validate(entity)
|
|
41
|
+
if is_masked(entity):
|
|
42
|
+
return out.model_copy(update={"value": SENSITIVE_PLACEHOLDER})
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_placeholder_write(entity: Setting, value: object) -> bool:
|
|
47
|
+
"""Whether this write is the mask being echoed back, not a real new value.
|
|
48
|
+
|
|
49
|
+
The admin edit form GETs the row, pre-fills its input from the response,
|
|
50
|
+
and PUTs it back. For a masked row that response carries ``"********"``, so
|
|
51
|
+
an admin who opens the row and clicks Save — without touching the field —
|
|
52
|
+
would otherwise overwrite a real credential with a fixed, publicly-known
|
|
53
|
+
string. On ``host.secret_key`` that silently invalidates every session and
|
|
54
|
+
makes every future cookie forgeable.
|
|
55
|
+
|
|
56
|
+
Gated on the stored row rather than on the sentinel alone, so a non-secret
|
|
57
|
+
row whose real value happens to be eight asterisks still saves.
|
|
58
|
+
|
|
59
|
+
Treated as "leave it alone" rather than rejected, so the rest of the form
|
|
60
|
+
still saves and an admin who genuinely types a new value can still set one.
|
|
61
|
+
"""
|
|
62
|
+
return is_masked(entity) and value == SENSITIVE_PLACEHOLDER
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def drop_placeholder_write(entity: Setting, changes: dict) -> None:
|
|
66
|
+
"""Strip a masked-value echo out of an update payload, in place."""
|
|
67
|
+
if "value" in changes and is_placeholder_write(entity, changes["value"]):
|
|
68
|
+
del changes["value"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""What counts as a secret on a settings field, and how one is hidden.
|
|
2
|
+
|
|
3
|
+
Split out of ``_module_settings`` so the "should this value be shown?"
|
|
4
|
+
question — a name rule, a value rule and one mask — lives in one file and can
|
|
5
|
+
be reasoned about without a FastAPI app in scope. Re-exported from
|
|
6
|
+
``_module_settings``, which is where the rest of the codebase imports it from.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
# We intentionally DON'T match the bare word "token" (would mask
|
|
16
|
+
# `verification_token_lifetime_seconds` — just an int) or "key" alone (would
|
|
17
|
+
# mask `s3_bucket_key_prefix`). Only fragments that actually indicate material.
|
|
18
|
+
_SECRET_PATTERNS = re.compile(
|
|
19
|
+
r"(password|secret|api[_-]?key|private[_-]?key|token[_-]?secret)", re.I
|
|
20
|
+
)
|
|
21
|
+
SECRET_MASK = "••••••••"
|
|
22
|
+
|
|
23
|
+
# Value types that cannot carry credential material, so a secret-ish *name*
|
|
24
|
+
# on one of them is a false positive rather than something to hide.
|
|
25
|
+
_NEVER_SECRET_TYPES = frozenset({"int", "float", "bool"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_secret_field(name: str) -> bool:
|
|
29
|
+
"""True if a field name suggests it holds credential material."""
|
|
30
|
+
return bool(_SECRET_PATTERNS.search(name))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_named_secret(name: str, value_type: str | None = None) -> bool:
|
|
34
|
+
"""True if the *name* marks this field as credential material.
|
|
35
|
+
|
|
36
|
+
``value_type`` exempts the types that cannot hold one, so a numeric field
|
|
37
|
+
whose name merely contains a secret-ish word is not masked and made
|
|
38
|
+
uneditable — ``reset_password_token_lifetime_seconds`` is an int, but it
|
|
39
|
+
matches on "password" exactly as the real secrets do. Phrased as "exempt
|
|
40
|
+
the types that cannot hold a credential" rather than "mask only strings" so
|
|
41
|
+
the failure direction is safe: an unexpected type (``str | None`` resolves
|
|
42
|
+
to "json") stays masked instead of silently exposing a secret.
|
|
43
|
+
"""
|
|
44
|
+
if value_type in _NEVER_SECRET_TYPES:
|
|
45
|
+
return False
|
|
46
|
+
return is_secret_field(name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def conceals_secret(name: str, value: Any, value_type: str | None = None) -> bool:
|
|
50
|
+
"""True if this name/value pair must be masked on the way out.
|
|
51
|
+
|
|
52
|
+
The one rule every read path shares: mask per *value* as well as per name,
|
|
53
|
+
so a DSN carrying a password is hidden even on a field called
|
|
54
|
+
``broker_url``, while that field's password-free default is still shown.
|
|
55
|
+
"""
|
|
56
|
+
return is_named_secret(name, value_type) or embeds_credential(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def embeds_credential(value: Any) -> bool:
|
|
60
|
+
"""True if ``value`` carries a password in a URL authority.
|
|
61
|
+
|
|
62
|
+
The name rule alone cannot see these: ``broker_url``, ``result_backend``,
|
|
63
|
+
``redis_url`` and ``database_url`` match nothing in
|
|
64
|
+
:data:`_SECRET_PATTERNS`, yet a production DSN is precisely where the
|
|
65
|
+
Redis and Postgres passwords live — and those fields are ``env_readable``,
|
|
66
|
+
so the value reached both the module editor and the ``known_keys``
|
|
67
|
+
suggestion list in clear text.
|
|
68
|
+
|
|
69
|
+
Judged on the value rather than the name because the name is the thing
|
|
70
|
+
that was wrong. A DSN without a password stays visible: hiding
|
|
71
|
+
``redis://localhost:6379/0`` helps nobody debug why the queue is idle.
|
|
72
|
+
|
|
73
|
+
Containers are walked rather than dismissed: a ``list[str]`` of broker
|
|
74
|
+
URLs or a ``dict`` of per-tenant DSNs holds exactly the same material as
|
|
75
|
+
the bare string, and returning False for anything non-``str`` meant one
|
|
76
|
+
such field would have been shown in full.
|
|
77
|
+
"""
|
|
78
|
+
if isinstance(value, str):
|
|
79
|
+
if "://" not in value:
|
|
80
|
+
return False
|
|
81
|
+
try:
|
|
82
|
+
return bool(urlsplit(value).password)
|
|
83
|
+
except ValueError:
|
|
84
|
+
# A malformed authority (an unclosed IPv6 literal, say) is not a
|
|
85
|
+
# credential, but it must not take the settings screen down either.
|
|
86
|
+
return False
|
|
87
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
88
|
+
return any(embeds_credential(item) for item in value)
|
|
89
|
+
if isinstance(value, dict):
|
|
90
|
+
return any(embeds_credential(item) for item in value.values())
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def mask(value: Any) -> Any:
|
|
95
|
+
if value in (None, "", [], {}):
|
|
96
|
+
return value
|
|
97
|
+
return SECRET_MASK
|