simple-module-settings 0.0.33__tar.gz → 0.0.35__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.33 → simple_module_settings-0.0.35}/PKG-INFO +4 -4
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/package.json +2 -2
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/pyproject.toml +4 -4
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/_module_settings.py +4 -12
- simple_module_settings-0.0.35/settings/_row_masking.py +68 -0
- simple_module_settings-0.0.35/settings/_secrets.py +97 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/browse_query.py +26 -8
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/endpoints/module_api.py +29 -10
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/endpoints/views.py +9 -6
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/known_keys.py +17 -14
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/Browse.tsx +8 -5
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/Create.tsx +1 -1
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/Edit.tsx +1 -1
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/ModulesEdit.tsx +1 -1
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/KeyField.tsx +0 -2
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/StoreTable.tsx +8 -1
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/service.py +59 -58
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/store.py +10 -3
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_cli_import.py +18 -8
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_known_keys_meta.py +0 -3
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_settings_secret_urls.py +45 -2
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_store_filters.py +26 -0
- simple_module_settings-0.0.35/tests/test_store_secret_masking.py +231 -0
- simple_module_settings-0.0.33/settings/_secrets.py +0 -60
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/.gitignore +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/LICENSE +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/README.md +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/__init__.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/_field_meta.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/_module_settings_props.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/cli.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/constants.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/contracts/__init__.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/contracts/accessor.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/contracts/events.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/contracts/registry.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/contracts/schemas.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/deps.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/endpoints/__init__.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/endpoints/api.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/env_vars.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/hydrate.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/locales/en.json +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/models.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/module.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/module_registry.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/FieldInput.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/FieldSource.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/ModuleFieldRow.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/ModuleForm.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/ResolvedValue.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/ScopeTabs.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/StoreCards.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/TestConnectionButton.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/ValueInput.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/components/last-test.ts +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/routes.ts +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/pages/types.ts +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/py.typed +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/registration.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/reload.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/services.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/settings.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_concurrent_overrides.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_hydrate.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_module_api.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_module_registry.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_module_settings.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_module_settings_render.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_module_settings_serialize.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_registration.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_reload.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_settings_accessor.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_settings_api.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_settings_events.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_settings_field_sources.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_settings_module.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_settings_schemas.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_settings_secret_masking.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_settings_service.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_settings_view_authz.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_store.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests/test_testable_checks.py +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests-js/ModuleForm.test.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tests-js/ScopeTabs.test.tsx +0 -0
- {simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/tsconfig.json +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.35
|
|
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.35
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.35
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.35
|
|
27
27
|
Requires-Dist: typer>=0.12
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
"peerDependencies": {
|
|
7
7
|
"react": "^19.0.0",
|
|
8
8
|
"react-dom": "^19.0.0",
|
|
9
|
-
"@inertiajs/react": "^
|
|
9
|
+
"@inertiajs/react": "^3.7.0",
|
|
10
10
|
"@simple-module-py/ui": "*"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@simple-module-py/tsconfig": "*"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"sonner": "^2.0.
|
|
16
|
+
"sonner": "^2.0.8"
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_settings"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.35"
|
|
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.35",
|
|
25
|
+
"simple_module_db==0.0.35",
|
|
26
|
+
"simple_module_hosting==0.0.35",
|
|
27
27
|
"typer>=0.12",
|
|
28
28
|
]
|
|
29
29
|
|
{simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/_module_settings.py
RENAMED
|
@@ -19,7 +19,9 @@ from settings._field_meta import choices_for, env_readable_var, resolve_default
|
|
|
19
19
|
from settings._secrets import ( # noqa: F401
|
|
20
20
|
_NEVER_SECRET_TYPES,
|
|
21
21
|
SECRET_MASK,
|
|
22
|
+
conceals_secret,
|
|
22
23
|
embeds_credential,
|
|
24
|
+
is_named_secret,
|
|
23
25
|
is_secret_field,
|
|
24
26
|
mask,
|
|
25
27
|
)
|
|
@@ -129,14 +131,7 @@ def _field_view(
|
|
|
129
131
|
info = cls.model_fields[name]
|
|
130
132
|
raw_value = getattr(settings, name)
|
|
131
133
|
value_type = value_type_for_field(cls, name)
|
|
132
|
-
|
|
133
|
-
# masked and made uneditable — `reset_password_token_lifetime_seconds` is
|
|
134
|
-
# an int, but it matches on "password" exactly as the real secrets do.
|
|
135
|
-
# Phrased as "exempt the types that cannot hold a credential" rather than
|
|
136
|
-
# "mask only strings" so the failure direction is safe: an unexpected type
|
|
137
|
-
# (e.g. `str | None`, which resolves to "json") stays masked instead of
|
|
138
|
-
# silently exposing a secret.
|
|
139
|
-
named_secret = value_type not in _NEVER_SECRET_TYPES and is_secret_field(name)
|
|
134
|
+
named_secret = is_named_secret(name, value_type)
|
|
140
135
|
extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
|
141
136
|
default = resolve_default(info)
|
|
142
137
|
env_var = f"{prefix}{name.upper()}"
|
|
@@ -147,10 +142,7 @@ def _field_view(
|
|
|
147
142
|
raw_env = os.environ[live_env_var] if env_set and live_env_var is not None else None
|
|
148
143
|
|
|
149
144
|
def hidden(value: Any) -> bool:
|
|
150
|
-
|
|
151
|
-
secret even on a field called ``broker_url``, while the same field's
|
|
152
|
-
password-free default is still worth showing."""
|
|
153
|
-
return named_secret or embeds_credential(value)
|
|
145
|
+
return conceals_secret(name, value, value_type)
|
|
154
146
|
|
|
155
147
|
# ``is_secret`` drives the editor's input type and the "reveal" affordance,
|
|
156
148
|
# so it has to be true when *any* of the three readings is being hidden —
|
|
@@ -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
|
|
@@ -11,6 +11,9 @@ exactly what the query ran with.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
from dataclasses import dataclass
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
from pydantic import BeforeValidator
|
|
14
17
|
|
|
15
18
|
from settings.constants import ALL_SCOPES, DEFAULT_PER_PAGE, MAX_PER_PAGE, SCOPE_ALL
|
|
16
19
|
from settings.contracts.schemas import SettingScope
|
|
@@ -32,18 +35,33 @@ class BrowseQuery:
|
|
|
32
35
|
return None if self.scope == SCOPE_ALL else SettingScope(self.scope)
|
|
33
36
|
|
|
34
37
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
def _lenient_int(fallback: int) -> BeforeValidator:
|
|
39
|
+
"""Coerce a query param to ``int``, substituting ``fallback`` for garbage.
|
|
40
|
+
|
|
41
|
+
Declared as a validator on an ``int``-annotated param rather than by typing
|
|
42
|
+
the param ``str``: both accept ``?page=banana`` without a 422, but only this
|
|
43
|
+
one leaves the OpenAPI schema advertising an integer. A generated client
|
|
44
|
+
that reads the schema was otherwise told to send page numbers as strings.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def coerce(raw: Any) -> int:
|
|
48
|
+
try:
|
|
49
|
+
return int(raw)
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return fallback
|
|
52
|
+
|
|
53
|
+
return BeforeValidator(coerce)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
PageParam = Annotated[int, _lenient_int(1)]
|
|
57
|
+
PerPageParam = Annotated[int, _lenient_int(DEFAULT_PER_PAGE)]
|
|
40
58
|
|
|
41
59
|
|
|
42
|
-
def parse(scope: str, q: str, page:
|
|
60
|
+
def parse(scope: str, q: str, page: int, per_page: int) -> BrowseQuery:
|
|
43
61
|
"""Read the four query params, substituting defaults for anything unusable."""
|
|
44
62
|
return BrowseQuery(
|
|
45
63
|
scope=scope if scope in ALL_SCOPES else SCOPE_ALL,
|
|
46
64
|
q=q,
|
|
47
|
-
page=max(
|
|
48
|
-
per_page=max(1, min(
|
|
65
|
+
page=max(page, 1),
|
|
66
|
+
per_page=max(1, min(per_page, MAX_PER_PAGE)),
|
|
49
67
|
)
|
{simple_module_settings-0.0.33 → simple_module_settings-0.0.35}/settings/endpoints/module_api.py
RENAMED
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
-
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
12
|
+
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response, status
|
|
13
13
|
from pydantic import ValidationError
|
|
14
14
|
from simple_module_hosting.permissions import RequiresPermission
|
|
15
15
|
|
|
@@ -37,19 +37,38 @@ _EDIT = [Depends(RequiresPermission(PERM_EDIT))]
|
|
|
37
37
|
_DELETE = [Depends(RequiresPermission(PERM_DELETE))]
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def
|
|
41
|
-
"""
|
|
40
|
+
def _masked_fields(app: FastAPI, package: str) -> frozenset[str]:
|
|
41
|
+
"""The fields this package's editor rendered as dots.
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
``ModuleSettingField.is_secret`` is the same flag that drove the input type
|
|
44
|
+
and the reveal affordance, so it answers exactly the question here: which
|
|
45
|
+
submitted values could be an echo of the mask rather than a real edit.
|
|
46
|
+
"""
|
|
47
|
+
return frozenset(
|
|
48
|
+
field.name
|
|
49
|
+
for view in collect_module_settings(app)
|
|
50
|
+
if view.package == package
|
|
51
|
+
for field in view.fields
|
|
52
|
+
if field.is_secret
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _strip_mask_sentinels(masked: frozenset[str], changes: dict[str, Any]) -> dict[str, Any]:
|
|
57
|
+
"""Drop the mask sentinel on the fields that were rendered masked.
|
|
58
|
+
|
|
59
|
+
Not on ``is_secret_field(name)``: a value can be masked because it *is* a
|
|
60
|
+
credential (a DSN with a password in it) on a field whose name says nothing
|
|
61
|
+
of the sort, and storing the row of dots the editor rendered would overwrite
|
|
62
|
+
the real connection string.
|
|
63
|
+
|
|
64
|
+
Not on the sentinel alone either, which is where this started — that silently
|
|
65
|
+
dropped the write on any field whose real value happened to equal
|
|
66
|
+
:data:`SECRET_MASK`, with no error and no saved row to show for it.
|
|
48
67
|
"""
|
|
49
68
|
return {
|
|
50
69
|
name: value
|
|
51
70
|
for name, value in changes.items()
|
|
52
|
-
if not (isinstance(value, str) and value == SECRET_MASK)
|
|
71
|
+
if not (name in masked and isinstance(value, str) and value == SECRET_MASK)
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
|
|
@@ -86,7 +105,7 @@ async def update_module(
|
|
|
86
105
|
detail="This module has a dedicated settings page; edit it there instead.",
|
|
87
106
|
)
|
|
88
107
|
|
|
89
|
-
cleaned = _strip_mask_sentinels(changes)
|
|
108
|
+
cleaned = _strip_mask_sentinels(_masked_fields(request.app, package), changes)
|
|
90
109
|
if not cleaned:
|
|
91
110
|
return {"ok": True, "changed": []}
|
|
92
111
|
|
|
@@ -13,11 +13,11 @@ import asyncio
|
|
|
13
13
|
import math
|
|
14
14
|
|
|
15
15
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
16
|
-
from inertia import InertiaResponse
|
|
17
16
|
from pydantic import ValidationError
|
|
18
17
|
from simple_module_hosting.inertia_deps import InertiaDep
|
|
19
18
|
from simple_module_hosting.inertia_utils import redirect_back_with_errors, validation_errors_to_dict
|
|
20
19
|
from simple_module_hosting.permissions import RequiresPermission
|
|
20
|
+
from simple_module_inertia import InertiaResponse
|
|
21
21
|
from starlette.responses import RedirectResponse
|
|
22
22
|
|
|
23
23
|
from settings import browse_query, known_keys
|
|
@@ -80,8 +80,8 @@ async def browse(
|
|
|
80
80
|
service: SettingService = Depends(get_setting_service),
|
|
81
81
|
scope: str = SCOPE_ALL,
|
|
82
82
|
q: str = "",
|
|
83
|
-
page:
|
|
84
|
-
per_page:
|
|
83
|
+
page: browse_query.PageParam = 1,
|
|
84
|
+
per_page: browse_query.PerPageParam = DEFAULT_PER_PAGE,
|
|
85
85
|
) -> InertiaResponse:
|
|
86
86
|
"""The raw key/value store, filtered/searched/paged on the server.
|
|
87
87
|
|
|
@@ -89,9 +89,12 @@ async def browse(
|
|
|
89
89
|
"Settings" is nearly always after a module's form, not a table of rows
|
|
90
90
|
keyed by dotted strings.
|
|
91
91
|
|
|
92
|
-
The filters
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
The filters reach us from urls people edit and bookmark, so nothing here
|
|
93
|
+
422s a link that used to work: an unusable ``scope`` falls back to the
|
|
94
|
+
``all`` tab, and the two page params carry a validator that substitutes
|
|
95
|
+
their default instead of rejecting the request. ``browse_query.parse``
|
|
96
|
+
then clamps, so the ``filters``/``pagination`` props can echo exactly what
|
|
97
|
+
the query ran with.
|
|
95
98
|
"""
|
|
96
99
|
query = browse_query.parse(scope, q, page, per_page)
|
|
97
100
|
items, total = await service.list_filtered(
|
|
@@ -19,19 +19,13 @@ from fastapi import FastAPI
|
|
|
19
19
|
from fastapi.encoders import jsonable_encoder
|
|
20
20
|
|
|
21
21
|
from settings._module_settings import collect_module_settings
|
|
22
|
+
from settings._secrets import conceals_secret, mask
|
|
22
23
|
|
|
23
|
-
_DEFINITION_MODULE = "Registry"
|
|
24
|
-
"""Module label for keys declared through ``SettingsRegistry`` rather than a
|
|
25
|
-
pydantic settings class. They have no owning package to name — the registry
|
|
26
|
-
records intent, not a field on some module's settings object."""
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
def _from_field(package: str, module_name: str, field) -> dict[str, Any]:
|
|
25
|
+
def _from_field(package: str, field) -> dict[str, Any]:
|
|
30
26
|
return {
|
|
31
27
|
"key": f"{package}.{field.name}",
|
|
32
28
|
"type": field.type,
|
|
33
|
-
"description": field.description,
|
|
34
|
-
"module": module_name,
|
|
35
29
|
"env_var": field.env_var,
|
|
36
30
|
# See ``ModuleSettingField.env_readable``: a bundled module's SM_* var
|
|
37
31
|
# is a label, not a fallback, and the panel must say so.
|
|
@@ -46,18 +40,27 @@ def _from_field(package: str, module_name: str, field) -> dict[str, Any]:
|
|
|
46
40
|
|
|
47
41
|
|
|
48
42
|
def _from_definition(definition) -> dict[str, Any]:
|
|
43
|
+
"""A registry declaration, masked on the same rule as a module field.
|
|
44
|
+
|
|
45
|
+
``_from_field`` receives a default that ``_module_settings`` has already
|
|
46
|
+
masked; this builder reads the declaration directly, so it has to apply the
|
|
47
|
+
rule itself. Hard-coding ``is_secret: False`` and passing ``default``
|
|
48
|
+
straight through put the raw value into the New-override suggestion list,
|
|
49
|
+
which renders it verbatim.
|
|
50
|
+
"""
|
|
51
|
+
value_type = str(definition.value_type)
|
|
52
|
+
default = jsonable_encoder(definition.default)
|
|
53
|
+
secret = conceals_secret(definition.key, default, value_type)
|
|
49
54
|
return {
|
|
50
55
|
"key": definition.key,
|
|
51
|
-
"type":
|
|
52
|
-
"description": definition.description,
|
|
53
|
-
"module": _DEFINITION_MODULE,
|
|
56
|
+
"type": value_type,
|
|
54
57
|
"env_var": "",
|
|
55
58
|
"env_readable": False,
|
|
56
59
|
"env_set": False,
|
|
57
60
|
"env_value": None,
|
|
58
|
-
"default":
|
|
61
|
+
"default": mask(default) if secret else default,
|
|
59
62
|
"requires_restart": False,
|
|
60
|
-
"is_secret":
|
|
63
|
+
"is_secret": secret,
|
|
61
64
|
"choices": None,
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -72,7 +75,7 @@ def build(app: FastAPI) -> list[dict[str, Any]]:
|
|
|
72
75
|
suggestions: dict[str, dict[str, Any]] = {}
|
|
73
76
|
for view in collect_module_settings(app):
|
|
74
77
|
for field in view.fields:
|
|
75
|
-
entry = _from_field(view.package,
|
|
78
|
+
entry = _from_field(view.package, field)
|
|
76
79
|
suggestions[entry["key"]] = entry
|
|
77
80
|
|
|
78
81
|
registry = getattr(getattr(app.state, "settings", None), "registry", None)
|
|
@@ -65,6 +65,7 @@ function Browse({ settings, pagination, counts, filters }: Props) {
|
|
|
65
65
|
<>
|
|
66
66
|
<Head title={t(keys.settings.browse.title)} />
|
|
67
67
|
<PageShell
|
|
68
|
+
fill
|
|
68
69
|
title={t(keys.settings.browse.title)}
|
|
69
70
|
description={t(keys.settings.browse.description)}
|
|
70
71
|
actions={
|
|
@@ -102,10 +103,12 @@ function Browse({ settings, pagination, counts, filters }: Props) {
|
|
|
102
103
|
</div>
|
|
103
104
|
</div>
|
|
104
105
|
|
|
105
|
-
{/*
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
{/* The deck's card fills the viewport with the footer pinned to the
|
|
107
|
+
bottom. `fill` on the shell hands the leftover height to this
|
|
108
|
+
column, so the card grows into it — the `15rem` this replaced was a
|
|
109
|
+
hand-measured stand-in for the heading, tabs and search bar above,
|
|
110
|
+
and drifted whenever any of them changed. */}
|
|
111
|
+
<Card className="flex flex-col overflow-hidden border-border p-0 lg:flex-1">
|
|
109
112
|
{settings.length === 0 ? (
|
|
110
113
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
|
|
111
114
|
<SettingsIcon className="size-8" aria-hidden="true" />
|
|
@@ -167,5 +170,5 @@ function Browse({ settings, pagination, counts, filters }: Props) {
|
|
|
167
170
|
);
|
|
168
171
|
}
|
|
169
172
|
|
|
170
|
-
Browse.layout =
|
|
173
|
+
Browse.layout = [AdminLayout];
|
|
171
174
|
export default Browse;
|
|
@@ -7,8 +7,6 @@ import type { ValueType } from '../types';
|
|
|
7
7
|
export interface KnownKey {
|
|
8
8
|
key: string;
|
|
9
9
|
type: string;
|
|
10
|
-
description: string;
|
|
11
|
-
module: string;
|
|
12
10
|
/** `SM_<PACKAGE>_<FIELD>` label for this field. */
|
|
13
11
|
env_var: string;
|
|
14
12
|
/** The declaring class reads env at all (it declares an `env_prefix`). */
|
|
@@ -85,7 +85,14 @@ export function StoreTable({ settings, onDelete }: Props) {
|
|
|
85
85
|
style={{ background: setting.value }}
|
|
86
86
|
/>
|
|
87
87
|
)}
|
|
88
|
-
|
|
88
|
+
{/* `title` carries the full text: the cell clips most of a
|
|
89
|
+
realistic value (a connection string measured 66% hidden at
|
|
90
|
+
1440px, not just on phones) and the row's only other route
|
|
91
|
+
to it is opening the editor. It repeats what is rendered,
|
|
92
|
+
so a masked secret stays masked here too. */}
|
|
93
|
+
<code title={setting.value} className="font-mono text-[13px] truncate">
|
|
94
|
+
{setting.value}
|
|
95
|
+
</code>
|
|
89
96
|
</div>
|
|
90
97
|
</TableCell>
|
|
91
98
|
<TableCell>
|