simple-module-core 0.0.29__tar.gz → 0.0.31__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.
Files changed (61) hide show
  1. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/.gitignore +1 -0
  2. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/PKG-INFO +2 -2
  3. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/pyproject.toml +1 -1
  4. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/__init__.py +6 -0
  5. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/__main__.py +8 -3
  6. simple_module_core-0.0.31/simple_module_core/audit_links.py +84 -0
  7. simple_module_core-0.0.31/simple_module_core/csp.py +140 -0
  8. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_module.py +1 -0
  9. simple_module_core-0.0.31/simple_module_core/dotenv.py +141 -0
  10. simple_module_core-0.0.31/simple_module_core/health.py +84 -0
  11. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/menu.py +20 -1
  12. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/module.py +41 -0
  13. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/services.py +2 -0
  14. simple_module_core-0.0.31/tests/test_audit_links.py +57 -0
  15. simple_module_core-0.0.31/tests/test_csp_registry.py +116 -0
  16. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_dotenv.py +21 -0
  17. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_health_registry.py +35 -0
  18. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_menu.py +49 -0
  19. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_services.py +3 -0
  20. simple_module_core-0.0.29/simple_module_core/dotenv.py +0 -69
  21. simple_module_core-0.0.29/simple_module_core/health.py +0 -46
  22. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/LICENSE +0 -0
  23. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/README.md +0 -0
  24. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/design_packs.py +0 -0
  25. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/__init__.py +0 -0
  26. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_coupling.py +0 -0
  27. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_css.py +0 -0
  28. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_i18n.py +0 -0
  29. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  30. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  31. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_migration.py +0 -0
  32. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_pages.py +0 -0
  33. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_runner.py +0 -0
  34. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_types.py +0 -0
  35. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/discovery.py +0 -0
  36. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/environments.py +0 -0
  37. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/events.py +0 -0
  38. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/exceptions.py +0 -0
  39. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/feature_flags.py +0 -0
  40. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/i18n.py +0 -0
  41. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/permissions.py +0 -0
  42. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/public_routes.py +0 -0
  43. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/py.typed +0 -0
  44. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/simple_module_core/versioning.py +0 -0
  45. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_auth_provider_selection.py +0 -0
  46. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_css_diagnostics.py +0 -0
  47. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_css_scanner.py +0 -0
  48. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_design_packs.py +0 -0
  49. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_diagnostics.py +0 -0
  50. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_discovery.py +0 -0
  51. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_environments.py +0 -0
  52. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_events.py +0 -0
  53. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_feature_flags.py +0 -0
  54. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_feature_flags_decorator.py +0 -0
  55. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_i18n.py +0 -0
  56. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_i18n_diagnostics.py +0 -0
  57. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_module_base.py +0 -0
  58. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_module_diagnostics.py +0 -0
  59. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_permissions.py +0 -0
  60. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_public_routes.py +0 -0
  61. {simple_module_core-0.0.29 → simple_module_core-0.0.31}/tests/test_versioning.py +0 -0
@@ -63,3 +63,4 @@ Thumbs.db
63
63
  host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
+ .verify/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simple_module_core
3
- Version: 0.0.29
3
+ Version: 0.0.31
4
4
  Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.29"
3
+ version = "0.0.31"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,5 +1,7 @@
1
1
  """SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""
2
2
 
3
+ from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
4
+ from simple_module_core.csp import CspSourceError, CspSourceRegistry
3
5
  from simple_module_core.design_packs import DesignPack, DesignPackRegistry
4
6
  from simple_module_core.diagnostics import (
5
7
  DiagnosticLevel,
@@ -44,7 +46,11 @@ from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework
44
46
  __all__ = [
45
47
  "DEFAULT_AUTH_PROVIDER",
46
48
  "FRAMEWORK_API_VERSION",
49
+ "AuditLink",
50
+ "AuditLinkRegistry",
47
51
  "CircularDependencyError",
52
+ "CspSourceError",
53
+ "CspSourceRegistry",
48
54
  "DesignPack",
49
55
  "DesignPackRegistry",
50
56
  "DiagnosticLevel",
@@ -9,7 +9,8 @@ Exits with status 1 if any ERROR-level diagnostics are reported.
9
9
 
10
10
  i18n checks are included when ``SM_I18N_SUPPORTED_LOCALES`` is set in env
11
11
  (or ``.env``). Host-level ``host/locales/`` and shared ``packages/ui/locales/``
12
- are picked up relative to ``SM_PROJECT_ROOT`` (or the current working dir).
12
+ are picked up relative to the project root (``SM_PROJECT_ROOT``, else the
13
+ directory of the discovered ``.env``, else the current working dir).
13
14
  """
14
15
 
15
16
  from __future__ import annotations
@@ -31,7 +32,7 @@ from simple_module_core.discovery import (
31
32
  select_auth_provider,
32
33
  topological_sort,
33
34
  )
34
- from simple_module_core.dotenv import parse_dotenv
35
+ from simple_module_core.dotenv import find_env_file, parse_dotenv
35
36
  from simple_module_core.exceptions import InvalidModuleError
36
37
 
37
38
 
@@ -64,7 +65,11 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
64
65
 
65
66
  def _discover_extra_locale_sources() -> list[tuple[str, str, Path]]:
66
67
  """Return ``[(reporter, namespace, path), ...]`` for host + ui locale dirs."""
67
- root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd())
68
+ # Anchor on the same project root the `.env` was loaded from
69
+ # (`parse_dotenv` walks up from the cwd) — resolving against the bare cwd
70
+ # here would look for `host/locales` in the wrong directory whenever
71
+ # doctor runs from a subdirectory.
72
+ root = find_env_file().parent
68
73
  out: list[tuple[str, str, Path]] = []
69
74
  host_locales = root / "host" / "locales"
70
75
  if host_locales.is_dir():
@@ -0,0 +1,84 @@
1
+ """Audit-link registry — modules teach the audit log how to reach their records.
2
+
3
+ An audit entry stores the *model class name* and primary key of the row that
4
+ changed (``StoredFile``, ``a91f3c2b…`` — ``snapshot_changes`` records
5
+ ``type(obj).__name__``, never ``__tablename__``). That is enough to prove what
6
+ happened and useless for doing anything about it: the reader has an id and no
7
+ way to open the record it names.
8
+
9
+ A module declares where its rows live via
10
+ :meth:`~simple_module_core.module.ModuleBase.register_audit_links`; the host
11
+ collects them into one registry at boot and stores it on
12
+ ``app.state.sm.audit_links``.
13
+
14
+ **The registry maps model class names to URL templates, nothing more.** It does
15
+ not verify the row exists or that the reader may open it — following a link to a
16
+ deleted record lands on that screen's own 404, and permissions are enforced by
17
+ the target route as usual.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ _ID_PLACEHOLDER = "{id}"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AuditLink:
29
+ """Where the records of one audited model can be viewed.
30
+
31
+ Args:
32
+ entity_type: The **model class name**, matching
33
+ ``AuditEntry.entity_type`` (e.g. ``"User"``, not ``"users_user"``).
34
+ ``snapshot_changes`` records ``type(obj).__name__``, so keying this
35
+ off ``__tablename__`` silently never matches — and because an
36
+ unmatched lookup falls back to showing ``entity_type`` as the
37
+ label, a table-name key looks like it worked.
38
+ url_template: Path containing ``{id}``, substituted with the entity id
39
+ (e.g. ``"/admin/users/{id}/edit"``).
40
+ label: Human-readable name for the entity kind, shown instead of the
41
+ raw class name (e.g. ``"User account"``).
42
+ """
43
+
44
+ entity_type: str
45
+ url_template: str
46
+ label: str = ""
47
+
48
+ def __post_init__(self) -> None:
49
+ if _ID_PLACEHOLDER not in self.url_template:
50
+ raise ValueError(
51
+ f"AuditLink for {self.entity_type!r} has url_template "
52
+ f"{self.url_template!r}, which contains no {_ID_PLACEHOLDER} — "
53
+ f"every row would link to the same page"
54
+ )
55
+
56
+ def url_for(self, entity_id: str) -> str:
57
+ return self.url_template.replace(_ID_PLACEHOLDER, entity_id)
58
+
59
+
60
+ class AuditLinkRegistry:
61
+ """Aggregates every module's :class:`AuditLink` declarations.
62
+
63
+ Populated once during boot (``register_audit_links`` hook) and read
64
+ thereafter by the audit log view when it renders each row.
65
+ """
66
+
67
+ def __init__(self) -> None:
68
+ self._links: dict[str, AuditLink] = {}
69
+
70
+ def register(self, link: AuditLink) -> None:
71
+ existing = self._links.get(link.entity_type)
72
+ if existing is not None and existing != link:
73
+ raise ValueError(
74
+ f"Two modules claim audit links for {link.entity_type!r}: "
75
+ f"{existing.url_template!r} and {link.url_template!r}"
76
+ )
77
+ self._links[link.entity_type] = link
78
+
79
+ def get(self, entity_type: str) -> AuditLink | None:
80
+ return self._links.get(entity_type)
81
+
82
+ @property
83
+ def all_links(self) -> dict[str, AuditLink]:
84
+ return dict(self._links)
@@ -0,0 +1,140 @@
1
+ """Registry for module-contributed Content-Security-Policy sources.
2
+
3
+ Modules whose frontend loads assets from an external origin (a font CDN, a
4
+ tile server, an analytics endpoint) declare those origins through
5
+ ``ModuleBase.register_csp_sources``. The host folds them into the CSP it
6
+ already ships — the module never rewrites the whole policy, and a typo'd
7
+ origin fails loudly at boot instead of silently weakening the header.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ __all__ = ["CspSourceError", "CspSourceRegistry"]
15
+
16
+ # Fetch directives a module may extend. Deliberately excludes the policy's
17
+ # structural directives (default-src, base-uri, form-action, frame-ancestors,
18
+ # sandbox): widening those changes the security posture of the whole app and
19
+ # belongs to the host operator, not a module.
20
+ _EXTENDABLE_DIRECTIVES = frozenset(
21
+ {
22
+ "script-src",
23
+ "script-src-elem",
24
+ "style-src",
25
+ "style-src-elem",
26
+ "img-src",
27
+ "font-src",
28
+ "connect-src",
29
+ "media-src",
30
+ "frame-src",
31
+ "worker-src",
32
+ "child-src",
33
+ }
34
+ )
35
+
36
+ # A source is a scheme, an origin (optionally with scheme/wildcard/port), or
37
+ # a data-ish scheme keyword. One token — anything that could smuggle a second
38
+ # token or terminate/extend the clause (whitespace, ";", ",", quotes) is
39
+ # rejected, and a wildcard prefix must be followed by a real host (`*.` alone
40
+ # is not a source).
41
+ _SOURCE_RE = re.compile(r"^(?:[A-Za-z][A-Za-z0-9+.-]*:(?://)?)?(?:(?:\*\.)?[^\s;,'\"*]+)?$")
42
+
43
+ # CSP3 fallback chains: when a directive is absent from a policy, the browser
44
+ # consults these directives in order (ending at ``default-src``). A clause we
45
+ # append for a previously-absent directive must be seeded from the nearest
46
+ # clause the policy already has along this chain — seeding with a bare
47
+ # ``'self'`` would *narrow* the policy (e.g. a fresh ``style-src-elem`` clause
48
+ # cuts off the fallback to ``style-src`` and silently drops its
49
+ # ``'unsafe-inline'`` and font origins).
50
+ _FALLBACK_CHAINS: dict[str, tuple[str, ...]] = {
51
+ "script-src-elem": ("script-src", "default-src"),
52
+ "style-src-elem": ("style-src", "default-src"),
53
+ "frame-src": ("child-src", "default-src"),
54
+ "worker-src": ("child-src", "script-src", "default-src"),
55
+ }
56
+
57
+
58
+ class CspSourceError(ValueError):
59
+ """Invalid CSP directive or source declared by a module."""
60
+
61
+
62
+ class CspSourceRegistry:
63
+ """Collects per-directive extra CSP sources from modules."""
64
+
65
+ def __init__(self) -> None:
66
+ self._sources: dict[str, list[str]] = {}
67
+
68
+ def add(self, directive: str, source: str) -> None:
69
+ """Allow ``source`` in ``directive``, e.g. ``add("style-src", "https://rsms.me")``."""
70
+ if directive not in _EXTENDABLE_DIRECTIVES:
71
+ raise CspSourceError(
72
+ f"CSP directive {directive!r} is not extendable; "
73
+ f"choose one of {sorted(_EXTENDABLE_DIRECTIVES)}"
74
+ )
75
+ token = source.strip()
76
+ if not token or not _SOURCE_RE.match(token):
77
+ raise CspSourceError(
78
+ f"invalid CSP source {source!r} for {directive}: must be a single "
79
+ "origin or scheme token (no spaces, quotes, wildcards-only, or ';')"
80
+ )
81
+ bucket = self._sources.setdefault(directive, [])
82
+ if token not in bucket:
83
+ bucket.append(token)
84
+
85
+ def __bool__(self) -> bool:
86
+ return bool(self._sources)
87
+
88
+ def extend_policy(self, policy: str) -> str:
89
+ """Fold the registered sources into an existing policy string.
90
+
91
+ Existing clauses keep their order and gain only sources they don't
92
+ already list. A directive absent from the policy is appended as a new
93
+ clause seeded from the clause the browser would otherwise have fallen
94
+ back to (per ``_FALLBACK_CHAINS``, ending at ``default-src``) —
95
+ without that seed the new clause would *narrow* the policy, since the
96
+ browser stops consulting the fallback once the directive exists.
97
+ """
98
+ if not self._sources:
99
+ return policy
100
+ directives: dict[str, list[str]] = {}
101
+ order: list[str] = []
102
+ for clause in policy.split(";"):
103
+ clause = clause.strip()
104
+ if not clause:
105
+ continue
106
+ directive, _, rest = clause.partition(" ")
107
+ directives[directive] = rest.split()
108
+ order.append(directive)
109
+ for directive, extras in self._sources.items():
110
+ if directive not in directives:
111
+ directives[directive] = list(self._seed_sources(directive, directives))
112
+ order.append(directive)
113
+ bucket = directives[directive]
114
+ bucket.extend(e for e in extras if e not in bucket)
115
+ # A more-specific directive *shadows* its fallback chain once present:
116
+ # with `script-src-elem` already in the policy, a module's
117
+ # `script-src` addition would never reach <script> elements — the
118
+ # very load the module declared it for. The same shadowing applies to
119
+ # every pair `_FALLBACK_CHAINS` encodes (an existing `frame-src` or
120
+ # `worker-src` clause shadows a module's `child-src` extras, and
121
+ # `worker-src` shadows `script-src` too). Mirror base-directive
122
+ # extras into each present shadowing clause so the declaration keeps
123
+ # exactly the meaning the fallback would have given it.
124
+ for shadowing, chain in _FALLBACK_CHAINS.items():
125
+ if shadowing not in directives:
126
+ continue
127
+ bucket = directives[shadowing]
128
+ for base in chain:
129
+ extras = self._sources.get(base)
130
+ if extras:
131
+ bucket.extend(e for e in extras if e not in bucket)
132
+ return "; ".join(f"{d} {' '.join(directives[d])}".rstrip() for d in order)
133
+
134
+ @staticmethod
135
+ def _seed_sources(directive: str, directives: dict[str, list[str]]) -> list[str]:
136
+ """Sources a brand-new clause inherits from its CSP fallback chain."""
137
+ for fallback in (*_FALLBACK_CHAINS.get(directive, ()), "default-src"):
138
+ if fallback in directives:
139
+ return directives[fallback]
140
+ return ["'self'"]
@@ -98,6 +98,7 @@ class ModuleDiagnostics:
98
98
  "register_middleware",
99
99
  "register_health_checks",
100
100
  "register_public_routes",
101
+ "register_csp_sources",
101
102
  "register_exception_handlers",
102
103
  "register_settings",
103
104
  "template_dirs",
@@ -0,0 +1,141 @@
1
+ """Minimal ``.env`` parser + env-var helpers — dependency-free.
2
+
3
+ Used in places that can't or shouldn't pull in ``pydantic-settings`` (the
4
+ diagnostics CLI runs before the host package is imported; the users-module
5
+ bootstrap runs after settings are constructed and needs to read values that
6
+ ``UsersSettings`` deliberately omits from ``env_file``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ BOOL_LITERALS_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
15
+ BOOL_LITERALS_FALSE = frozenset({"0", "false", "f", "no", "n", "off"})
16
+
17
+ # How many parent directories to probe for a `.env` above the cwd. One level
18
+ # covers the workspace layout (`host/` → root); a couple more cover running
19
+ # from `modules/<name>/`. Bounded so an unrelated `.env` far up the tree
20
+ # (e.g. in $HOME) is never picked up by accident.
21
+ _ENV_WALK_LIMIT = 4
22
+
23
+
24
+ def find_env_file() -> Path:
25
+ """Locate the project ``.env`` regardless of which subdirectory runs us.
26
+
27
+ ``$SM_PROJECT_ROOT/.env`` wins when set. Otherwise walk up from the cwd:
28
+ the web process chdirs to the workspace root so this finds ``./.env``
29
+ immediately, while a CLI invoked from ``host/`` or ``modules/<name>/``
30
+ finds the same file its app uses instead of silently loading nothing.
31
+ When the walk hits a project-root marker (``.git`` / ``.env.example``)
32
+ without finding a ``.env``, the returned path is ``<root>/.env`` — it may
33
+ not exist, but its *parent* still anchors relative sqlite paths at the
34
+ project root (a fresh scaffold has ``.env.example`` before any ``.env``).
35
+ Falls back to a cwd-relative ``Path(".env")`` when nothing is found.
36
+
37
+ This is the one .env-resolution convention for the whole ecosystem: the
38
+ settings layer (``BootstrapSettings``) and every out-of-process tool
39
+ (diagnostics CLI, worker entrypoints, users bootstrap) resolve through
40
+ here, so they can never disagree about which file is in effect. Compare
41
+ ``app_builder._resolve_project_root`` in the hosting package — a
42
+ separate walk that anchors the static/i18n root instead; the two are
43
+ kept distinct on purpose (see that function's docstring).
44
+ """
45
+ explicit = os.environ.get("SM_PROJECT_ROOT")
46
+ if explicit:
47
+ return Path(explicit) / ".env"
48
+ current = Path.cwd()
49
+ try:
50
+ home: Path | None = Path.home()
51
+ except RuntimeError:
52
+ # $HOME unset and no passwd entry for the UID (rootless containers,
53
+ # some CI sandboxes) — Path.home() can't resolve. Skip the
54
+ # home-boundary check rather than crash; the walk is still bounded
55
+ # by _ENV_WALK_LIMIT and the world-writable-dir guard below.
56
+ home = None
57
+ for candidate in (current, *current.parents[:_ENV_WALK_LIMIT]):
58
+ if home is not None and candidate == home:
59
+ break
60
+ # Never probe a shared world-writable ancestor (/tmp, /var/tmp):
61
+ # its `.env` could belong to anyone — including another local user —
62
+ # and callers merge every key of the discovered file into os.environ.
63
+ # The starting cwd itself is exempt: running *from* such a directory
64
+ # keeps the pre-existing "load the cwd's .env" behavior via the fallback.
65
+ if candidate != current and _is_world_writable_dir(candidate):
66
+ break
67
+ env = candidate / ".env"
68
+ if env.is_file():
69
+ return env
70
+ # A `.git` or `.env.example` marks a project root: never ascend past
71
+ # one, or a nested checkout (a git worktree, a repo inside another
72
+ # repo, a fresh scaffold — which ships `.env.example` before any
73
+ # `.git` exists) would silently load the *outer* project's `.env`.
74
+ # The boundary directory IS the project root, so anchor there: a
75
+ # fresh scaffold with only `.env.example` must still resolve
76
+ # relative sqlite paths against its root, not the caller's cwd.
77
+ if (candidate / ".git").exists() or (candidate / ".env.example").is_file():
78
+ return candidate / ".env"
79
+ return Path(".env")
80
+
81
+
82
+ def _is_world_writable_dir(path: Path) -> bool:
83
+ """True for shared scratch dirs like ``/tmp`` (world-writable, sticky or not)."""
84
+ try:
85
+ mode = path.stat().st_mode
86
+ except OSError:
87
+ return False
88
+ return bool(mode & 0o002)
89
+
90
+
91
+ def parse_dotenv(path: Path | None = None) -> dict[str, str]:
92
+ """Parse a ``.env`` file into a dict. Empty dict if the file is missing.
93
+
94
+ Values surrounded by matching single or double quotes have the quotes
95
+ stripped. Does *not* handle escapes, ``export KEY=…``, or multiline
96
+ values — keep the file simple. Does *not* mutate ``os.environ``; the
97
+ caller decides whether to merge.
98
+
99
+ Without ``path``, resolves via :func:`find_env_file` — the convention
100
+ used by every tool in this repo.
101
+ """
102
+ if path is None:
103
+ path = find_env_file()
104
+ if not path.is_file():
105
+ return {}
106
+ parsed: dict[str, str] = {}
107
+ for raw in path.read_text(encoding="utf-8").splitlines():
108
+ line = raw.strip()
109
+ if not line or line.startswith("#") or "=" not in line:
110
+ continue
111
+ key, _, value = line.partition("=")
112
+ parsed[key.strip()] = value.strip().strip('"').strip("'")
113
+ return parsed
114
+
115
+
116
+ def load_dotenv_into_environ(path: Path | None = None) -> None:
117
+ """Merge ``parse_dotenv(path)`` into ``os.environ`` via ``setdefault``.
118
+
119
+ Same precedence as the web process under uvicorn: real environment wins
120
+ over file values. Worker entrypoints call this before importing settings.
121
+ """
122
+ for key, value in parse_dotenv(path).items():
123
+ os.environ.setdefault(key, value)
124
+
125
+
126
+ def env_str(name: str, default: str) -> str:
127
+ """Return ``$name`` if set and non-empty, else ``default``."""
128
+ value = os.environ.get(name, "").strip()
129
+ return value or default
130
+
131
+
132
+ def env_bool(name: str, default: bool = False) -> bool:
133
+ """Parse ``$name`` as a boolean, returning ``default`` when unset/blank."""
134
+ raw = os.environ.get(name, "").strip().lower()
135
+ if not raw:
136
+ return default
137
+ if raw in BOOL_LITERALS_TRUE:
138
+ return True
139
+ if raw in BOOL_LITERALS_FALSE:
140
+ return False
141
+ return default
@@ -0,0 +1,84 @@
1
+ """Health check registry for module-contributed health checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+
9
+
10
+ class HealthStatus(StrEnum):
11
+ HEALTHY = "healthy"
12
+ DEGRADED = "degraded"
13
+ UNHEALTHY = "unhealthy"
14
+
15
+
16
+ @dataclass
17
+ class HealthCheckResult:
18
+ """Result of a single health check."""
19
+
20
+ status: HealthStatus
21
+ detail: str | None = None
22
+
23
+
24
+ HealthCheckFn = Callable[[], Awaitable[HealthCheckResult]]
25
+
26
+
27
+ @dataclass
28
+ class HealthCheck:
29
+ """A named health check with an async callable."""
30
+
31
+ name: str
32
+ check: HealthCheckFn
33
+ module: str = ""
34
+ """Module that contributed the check. Stamped by the registry during
35
+ ``register_health_checks``; module authors never set it by hand."""
36
+ probe: bool = True
37
+ """Whether automatic pollers may run this check.
38
+
39
+ Set ``False`` for checks that reach a third party — an SMTP login, an S3
40
+ request. Readiness is asked on a timer (a Kubernetes probe every 10s), and
41
+ a check that authenticates against a mail provider on that schedule earns
42
+ a rate-limit and binds probe latency to someone else's uptime. Such a
43
+ dependency is also not a readiness signal: the app serves pages fine while
44
+ its mailer is down.
45
+
46
+ ``False`` checks still run when explicitly requested — that is what the
47
+ "Test connection" action on the module-settings screen invokes.
48
+ """
49
+
50
+
51
+ class HealthRegistry:
52
+ """Collects health checks contributed by modules."""
53
+
54
+ def __init__(self) -> None:
55
+ self._checks: list[HealthCheck] = []
56
+ self._current_owner: str = ""
57
+
58
+ def set_owner(self, module_name: str) -> None:
59
+ """Attribute subsequently-added checks to ``module_name``.
60
+
61
+ The host calls this around each ``register_health_checks`` hook so a
62
+ check knows which module it belongs to without changing the ``add``
63
+ signature module authors already use. Attribution is what lets the
64
+ dashboard show health per module rather than one global number.
65
+ """
66
+ self._current_owner = module_name
67
+
68
+ def add(self, check: HealthCheck) -> None:
69
+ if not check.module:
70
+ check.module = self._current_owner
71
+ self._checks.append(check)
72
+
73
+ @property
74
+ def all_checks(self) -> list[HealthCheck]:
75
+ """Every registered check, including on-demand ones.
76
+
77
+ Callers that poll on a timer want :attr:`probe_checks` instead.
78
+ """
79
+ return list(self._checks)
80
+
81
+ @property
82
+ def probe_checks(self) -> list[HealthCheck]:
83
+ """Checks safe to run automatically, on a timer."""
84
+ return [c for c in self._checks if c.probe]
@@ -30,6 +30,15 @@ class MenuItem:
30
30
  requires_auth: bool = True
31
31
  roles: list[str] = field(default_factory=list)
32
32
  """Empty list = visible to all authenticated users."""
33
+ permissions: list[str] = field(default_factory=list)
34
+ """Permission keys required to see this entry. Empty = no permission check.
35
+
36
+ Declare the same permission the target route enforces. Roles alone cannot
37
+ express this: a custom role holding ``settings.view`` should see Settings,
38
+ and hard-coding ``roles=["admin"]`` would hide it from them while still
39
+ showing it to any admin-adjacent role that cannot actually open it. An
40
+ entry that 403s on click is worse than no entry at all.
41
+ """
33
42
  method: MenuItemMethod = "get"
34
43
  """HTTP method used when the item is activated. ``"post"`` renders as an
35
44
  Inertia form submission so the target endpoint can be POST-only (e.g. logout)."""
@@ -69,12 +78,18 @@ class MenuRegistry:
69
78
  *,
70
79
  is_authenticated: bool,
71
80
  roles: list[str] | None = None,
81
+ permissions: list[str] | None = None,
72
82
  ) -> dict[str, list[dict]]:
73
- """Return menu items grouped by section, filtered by auth/roles.
83
+ """Return menu items grouped by section, filtered by auth/roles/permissions.
84
+
85
+ ``permissions`` is the caller's already-expanded permission list (no
86
+ wildcards). Items declaring permissions the caller lacks are dropped,
87
+ so the sidebar never offers a screen that will 403 on click.
74
88
 
75
89
  Returns a dict ready to be serialized into Inertia shared props.
76
90
  """
77
91
  roles = roles or []
92
+ granted = set(permissions or [])
78
93
  result: dict[str, list[dict]] = {s.value: [] for s in MenuSection}
79
94
 
80
95
  for item in self.all_items:
@@ -82,6 +97,10 @@ class MenuRegistry:
82
97
  continue
83
98
  if item.roles and not any(r in item.roles for r in roles):
84
99
  continue
100
+ # All declared permissions must be held: an entry naming several is
101
+ # asking for all of them, matching how route guards compose.
102
+ if item.permissions and not granted.issuperset(item.permissions):
103
+ continue
85
104
  result[item.section.value].append(
86
105
  {
87
106
  "label": item.label,
@@ -10,6 +10,8 @@ from typing import TYPE_CHECKING
10
10
  if TYPE_CHECKING:
11
11
  from fastapi import APIRouter, FastAPI
12
12
 
13
+ from simple_module_core.audit_links import AuditLinkRegistry
14
+ from simple_module_core.csp import CspSourceRegistry
13
15
  from simple_module_core.design_packs import DesignPackRegistry
14
16
  from simple_module_core.events import EventBus
15
17
  from simple_module_core.feature_flags import FeatureFlagRegistry
@@ -147,6 +149,23 @@ class ModuleBase(ABC):
147
149
  mutations. Called once at boot, in dependency order.
148
150
  """
149
151
 
152
+ def register_csp_sources(self, registry: CspSourceRegistry) -> None:
153
+ """Declare external origins this module's frontend loads assets from.
154
+
155
+ The host ships a strict Content-Security-Policy; a module whose
156
+ pages pull a stylesheet, font, or API from another origin must
157
+ declare it here or the browser blocks the request::
158
+
159
+ def register_csp_sources(self, registry):
160
+ registry.add("style-src", "https://rsms.me")
161
+ registry.add("font-src", "https://rsms.me")
162
+
163
+ Only fetch directives can be extended (never default-src,
164
+ base-uri, form-action, frame-ancestors), and each source must be a
165
+ single origin/scheme token — invalid declarations raise at boot.
166
+ Called once, in dependency order.
167
+ """
168
+
150
169
  def register_design_packs(self, registry: DesignPackRegistry) -> None:
151
170
  """Declare design packs this module ships for the public site.
152
171
 
@@ -163,6 +182,28 @@ class ModuleBase(ABC):
163
182
  order.
164
183
  """
165
184
 
185
+ def register_audit_links(self, registry: AuditLinkRegistry) -> None:
186
+ """Declare where this module's audited records can be viewed.
187
+
188
+ An audit entry stores a table name and a primary key, which tells the
189
+ reader what changed but gives them no way to open it. Override this
190
+ hook to make your module's rows reachable from the audit log::
191
+
192
+ def register_audit_links(self, registry):
193
+ registry.register(
194
+ AuditLink(
195
+ entity_type="users_user",
196
+ url_template="/admin/users/{id}/edit",
197
+ label="User",
198
+ )
199
+ )
200
+
201
+ ``entity_type`` is the ``__tablename__`` the rows are audited under.
202
+ Registering only supplies the URL — the target route still enforces
203
+ its own permissions, so linking never widens access. Called once at
204
+ boot, in dependency order.
205
+ """
206
+
166
207
  def register_middleware(self, app: FastAPI) -> None:
167
208
  """Add middleware to the application.
168
209
 
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
20
20
  from simple_module_db.session import DatabaseState
21
21
  from simple_module_hosting.settings import Settings
22
22
 
23
+ from simple_module_core.audit_links import AuditLinkRegistry
23
24
  from simple_module_core.design_packs import DesignPackRegistry
24
25
  from simple_module_core.events import EventBus
25
26
  from simple_module_core.feature_flags import FeatureFlagRegistry
@@ -44,6 +45,7 @@ class Services:
44
45
  health_registry: HealthRegistry
45
46
  public_routes: PublicRouteRegistry
46
47
  design_packs: DesignPackRegistry
48
+ audit_links: AuditLinkRegistry
47
49
  i18n_registry: I18nRegistry
48
50
  inertia_config: InertiaConfig
49
51
  modules: tuple[ModuleBase, ...]
@@ -0,0 +1,57 @@
1
+ """Tests for AuditLink / AuditLinkRegistry.
2
+
3
+ The audit log stores a table name and a primary key. This registry is how a
4
+ module says where those rows can be opened, so the log stops being a wall of
5
+ unactionable uuids.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+ from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
12
+
13
+
14
+ class TestAuditLink:
15
+ def test_url_for_substitutes_the_id(self):
16
+ link = AuditLink(entity_type="User", url_template="/users/admin/{id}")
17
+ assert link.url_for("a91f3c2b") == "/users/admin/a91f3c2b"
18
+
19
+ def test_template_without_placeholder_is_rejected(self):
20
+ """Every row would otherwise link to the same page."""
21
+ with pytest.raises(ValueError, match="contains no"):
22
+ AuditLink(entity_type="User", url_template="/users/admin")
23
+
24
+ def test_label_defaults_to_empty(self):
25
+ assert AuditLink(entity_type="t", url_template="/t/{id}").label == ""
26
+
27
+
28
+ class TestAuditLinkRegistry:
29
+ def test_get_returns_none_for_unclaimed_tables(self):
30
+ """Join tables and blob stores have no screen; that is not an error."""
31
+ assert AuditLinkRegistry().get("UserPermission") is None
32
+
33
+ def test_register_and_get(self):
34
+ reg = AuditLinkRegistry()
35
+ link = AuditLink(entity_type="User", url_template="/users/admin/{id}", label="User")
36
+ reg.register(link)
37
+ assert reg.get("User") is link
38
+
39
+ def test_conflicting_claims_raise(self):
40
+ """Two modules mapping one table means one of them silently loses."""
41
+ reg = AuditLinkRegistry()
42
+ reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
43
+ with pytest.raises(ValueError, match="Two modules claim"):
44
+ reg.register(AuditLink(entity_type="User", url_template="/b/{id}"))
45
+
46
+ def test_registering_the_same_link_twice_is_allowed(self):
47
+ """Idempotent re-registration must not break a re-entrant boot."""
48
+ reg = AuditLinkRegistry()
49
+ for _ in range(2):
50
+ reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
51
+ assert len(reg.all_links) == 1
52
+
53
+ def test_all_links_is_a_copy(self):
54
+ reg = AuditLinkRegistry()
55
+ reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
56
+ reg.all_links.clear()
57
+ assert reg.get("User") is not None
@@ -0,0 +1,116 @@
1
+ """Modules can extend the host Content-Security-Policy with extra origins.
2
+
3
+ Found in the field: the pagebuilder module's editor loads a stylesheet from
4
+ ``rsms.me``, which the framework CSP blocks — and there was no way for the
5
+ module (or the host) to declare that origin short of patching the installed
6
+ package. ``register_csp_sources`` closes that gap the same way
7
+ ``register_public_routes`` does for auth exemptions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+ from simple_module_core import ModuleBase, ModuleMeta
14
+ from simple_module_core.csp import CspSourceError, CspSourceRegistry
15
+
16
+ _POLICY = (
17
+ "default-src 'self'; "
18
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
19
+ "font-src 'self' https://fonts.gstatic.com data:"
20
+ )
21
+
22
+
23
+ class TestRegistry:
24
+ def test_extends_existing_directive(self) -> None:
25
+ reg = CspSourceRegistry()
26
+ reg.add("style-src", "https://rsms.me")
27
+ out = reg.extend_policy(_POLICY)
28
+ assert (
29
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://rsms.me" in out
30
+ )
31
+
32
+ def test_duplicate_and_already_present_sources_are_not_repeated(self) -> None:
33
+ reg = CspSourceRegistry()
34
+ reg.add("style-src", "https://rsms.me")
35
+ reg.add("style-src", "https://rsms.me")
36
+ reg.add("style-src", "https://fonts.googleapis.com") # already in the policy
37
+ out = reg.extend_policy(_POLICY)
38
+ assert out.count("https://rsms.me") == 1
39
+ assert out.count("https://fonts.googleapis.com") == 1
40
+
41
+ def test_missing_directive_gets_self_plus_source(self) -> None:
42
+ """A brand-new clause must keep 'self', or it would *narrow* the policy:
43
+ without the clause the browser falls back to default-src 'self'."""
44
+ reg = CspSourceRegistry()
45
+ reg.add("connect-src", "https://api.example.com")
46
+ out = reg.extend_policy(_POLICY)
47
+ assert "connect-src 'self' https://api.example.com" in out
48
+
49
+ def test_missing_elem_directive_inherits_its_fallback_clause(self) -> None:
50
+ """style-src-elem falls back to style-src (not default-src): a fresh
51
+ clause must inherit style-src's sources, or creating it would silently
52
+ drop 'unsafe-inline' and the font origins from element styles."""
53
+ reg = CspSourceRegistry()
54
+ reg.add("style-src-elem", "https://rsms.me")
55
+ out = reg.extend_policy(_POLICY)
56
+ assert (
57
+ "style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com https://rsms.me"
58
+ in out
59
+ )
60
+
61
+ def test_empty_registry_returns_policy_unchanged(self) -> None:
62
+ reg = CspSourceRegistry()
63
+ assert reg.extend_policy(_POLICY) == _POLICY
64
+ assert not reg
65
+
66
+ def test_unknown_directive_rejected(self) -> None:
67
+ reg = CspSourceRegistry()
68
+ with pytest.raises(CspSourceError, match="directive"):
69
+ reg.add("script-src-attr; evil", "https://x.example")
70
+
71
+ def test_injection_shaped_sources_rejected(self) -> None:
72
+ reg = CspSourceRegistry()
73
+ for bad in (
74
+ "https://x; script-src *",
75
+ "https://x 'unsafe-eval'",
76
+ "",
77
+ "'*'",
78
+ "https://a.example,https://b.example", # comma would smuggle a 2nd policy
79
+ "*.", # wildcard with no host
80
+ ):
81
+ with pytest.raises(CspSourceError):
82
+ reg.add("style-src", bad)
83
+
84
+ def test_base_directive_extras_reach_existing_elem_clause(self) -> None:
85
+ """`script-src-elem` shadows `script-src` once present: without the
86
+ mirror, a module's script-src origin never reaches <script> elements —
87
+ the very load it declared the origin for."""
88
+ reg = CspSourceRegistry()
89
+ reg.add("script-src", "https://cdn.example.com")
90
+ policy = "default-src 'self'; script-src 'self'; script-src-elem 'self' 'unsafe-inline'"
91
+ out = reg.extend_policy(policy)
92
+ assert "script-src 'self' https://cdn.example.com" in out
93
+ assert "script-src-elem 'self' 'unsafe-inline' https://cdn.example.com" in out
94
+
95
+ def test_child_src_extras_reach_existing_frame_and_worker_clauses(self) -> None:
96
+ """`frame-src` and `worker-src` shadow `child-src` once present (the
97
+ host default policy ships both): without the mirror, a module's
98
+ child-src origin would never apply to frames or workers — the loads
99
+ the fallback chain says child-src governs."""
100
+ reg = CspSourceRegistry()
101
+ reg.add("child-src", "https://embed.example")
102
+ policy = "default-src 'self'; frame-src 'self'; worker-src 'self' blob:"
103
+ out = reg.extend_policy(policy)
104
+ assert "frame-src 'self' https://embed.example" in out
105
+ assert "worker-src 'self' blob: https://embed.example" in out
106
+ assert "child-src 'self' https://embed.example" in out # new clause, seeded
107
+
108
+
109
+ class TestHook:
110
+ def test_register_csp_sources_is_a_noop_by_default(self) -> None:
111
+ class Quiet(ModuleBase):
112
+ meta = ModuleMeta(name="Quiet")
113
+
114
+ reg = CspSourceRegistry()
115
+ Quiet().register_csp_sources(reg)
116
+ assert not reg
@@ -7,10 +7,14 @@ here is hard to debug because it manifests as "the setting just isn't there".
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import pathlib
11
+ from pathlib import Path
12
+
10
13
  import pytest
11
14
  from simple_module_core.dotenv import (
12
15
  env_bool,
13
16
  env_str,
17
+ find_env_file,
14
18
  load_dotenv_into_environ,
15
19
  parse_dotenv,
16
20
  )
@@ -73,6 +77,23 @@ class TestParseDotenv:
73
77
  assert parse_dotenv() == {"CWD_KEY": "present"}
74
78
 
75
79
 
80
+ class TestFindEnvFile:
81
+ def test_home_lookup_failure_skips_boundary_check(self, tmp_path, monkeypatch):
82
+ """``Path.home()`` raises ``RuntimeError`` when ``$HOME`` is unset and
83
+ the UID has no passwd entry (rootless containers). ``find_env_file``
84
+ must not propagate that — it just skips the home-boundary check and
85
+ keeps walking.
86
+ """
87
+
88
+ def _raise_runtime_error() -> Path:
89
+ raise RuntimeError("no home directory for this uid")
90
+
91
+ monkeypatch.setattr(pathlib.Path, "home", staticmethod(_raise_runtime_error))
92
+ monkeypatch.delenv("SM_PROJECT_ROOT", raising=False)
93
+ monkeypatch.chdir(tmp_path)
94
+ assert find_env_file() == Path(".env")
95
+
96
+
76
97
  class TestLoadDotenvIntoEnviron:
77
98
  def test_setdefault_semantics_preserves_existing_env(self, tmp_path, monkeypatch):
78
99
  """Real ``os.environ`` wins over file values — same precedence as uvicorn."""
@@ -33,6 +33,41 @@ class TestHealthRegistry:
33
33
  reg.add(HealthCheck(name="b", check=check_b))
34
34
  assert len(reg.all_checks) == 2
35
35
 
36
+ async def test_checks_are_attributed_to_the_owning_module(self):
37
+ """The dashboard shows health per module, which needs this attribution."""
38
+ reg = HealthRegistry()
39
+
40
+ async def check() -> HealthCheckResult:
41
+ return HealthCheckResult(status=HealthStatus.HEALTHY)
42
+
43
+ reg.set_owner("FileStorage")
44
+ reg.add(HealthCheck(name="s3", check=check))
45
+ reg.set_owner("BackgroundTasks")
46
+ reg.add(HealthCheck(name="broker", check=check))
47
+
48
+ owners = {c.name: c.module for c in reg.all_checks}
49
+ assert owners == {"s3": "FileStorage", "broker": "BackgroundTasks"}
50
+
51
+ async def test_explicit_module_survives_the_current_owner(self):
52
+ reg = HealthRegistry()
53
+
54
+ async def check() -> HealthCheckResult:
55
+ return HealthCheckResult(status=HealthStatus.HEALTHY)
56
+
57
+ reg.set_owner("Dashboard")
58
+ reg.add(HealthCheck(name="db", check=check, module="Users"))
59
+ assert reg.all_checks[0].module == "Users"
60
+
61
+ async def test_unowned_checks_have_no_module(self):
62
+ """Checks added outside a register_health_checks hook belong to nobody."""
63
+ reg = HealthRegistry()
64
+
65
+ async def check() -> HealthCheckResult:
66
+ return HealthCheckResult(status=HealthStatus.HEALTHY)
67
+
68
+ reg.add(HealthCheck(name="db", check=check))
69
+ assert reg.all_checks[0].module == ""
70
+
36
71
  async def test_check_result_defaults(self):
37
72
  result = HealthCheckResult(status=HealthStatus.HEALTHY)
38
73
  assert result.detail is None
@@ -114,3 +114,52 @@ class TestMenuRegistryAdvanced:
114
114
  result = reg.get_for_user(is_authenticated=True)
115
115
  groups = [i["group"] for i in result["sidebar"]]
116
116
  assert groups == ["Administration", "System"]
117
+
118
+
119
+ class TestPermissionFiltering:
120
+ """An entry that 403s on click is worse than no entry at all.
121
+
122
+ Roles alone could not express this: hard-coding ``roles=["admin"]`` hides
123
+ the screen from a custom role that legitimately holds the permission, while
124
+ still showing it to admin-adjacent roles that cannot open it.
125
+ """
126
+
127
+ async def test_entry_hidden_without_the_permission(self):
128
+ reg = MenuRegistry()
129
+ reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
130
+ result = reg.get_for_user(is_authenticated=True, permissions=["users.manage"])
131
+ assert result["sidebar"] == []
132
+
133
+ async def test_entry_shown_with_the_permission(self):
134
+ reg = MenuRegistry()
135
+ reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
136
+ result = reg.get_for_user(is_authenticated=True, permissions=["settings.view"])
137
+ assert [i["label"] for i in result["sidebar"]] == ["Settings"]
138
+
139
+ async def test_permission_granted_by_a_custom_role_is_enough(self):
140
+ """No role check involved — holding the key is what matters."""
141
+ reg = MenuRegistry()
142
+ reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
143
+ result = reg.get_for_user(
144
+ is_authenticated=True, roles=["auditor"], permissions=["settings.view"]
145
+ )
146
+ assert len(result["sidebar"]) == 1
147
+
148
+ async def test_all_declared_permissions_are_required(self):
149
+ reg = MenuRegistry()
150
+ reg.add(MenuItem(label="X", url="/x", permissions=["a.view", "a.manage"]))
151
+ assert reg.get_for_user(is_authenticated=True, permissions=["a.view"])["sidebar"] == []
152
+ both = reg.get_for_user(is_authenticated=True, permissions=["a.view", "a.manage"])
153
+ assert len(both["sidebar"]) == 1
154
+
155
+ async def test_no_declared_permissions_means_no_check(self):
156
+ """Existing entries must keep working without opting in."""
157
+ reg = MenuRegistry()
158
+ reg.add(MenuItem(label="Home", url="/"))
159
+ assert len(reg.get_for_user(is_authenticated=True, permissions=[])["sidebar"]) == 1
160
+
161
+ async def test_omitting_permissions_entirely_hides_gated_entries(self):
162
+ """A caller that passes no permissions holds none — fail closed."""
163
+ reg = MenuRegistry()
164
+ reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
165
+ assert reg.get_for_user(is_authenticated=True)["sidebar"] == []
@@ -31,6 +31,7 @@ class TestServices:
31
31
  assert s.health_registry is _SENTINEL_HEALTH
32
32
  assert s.public_routes is _SENTINEL_PUBLIC_ROUTES
33
33
  assert s.design_packs is _SENTINEL_DESIGN_PACKS
34
+ assert s.audit_links is _SENTINEL_AUDIT_LINKS
34
35
  assert s.i18n_registry is _SENTINEL_I18N
35
36
  assert s.inertia_config is _SENTINEL_INERTIA
36
37
  assert s.modules == ()
@@ -45,6 +46,7 @@ _SENTINEL_FLAGS = object()
45
46
  _SENTINEL_HEALTH = object()
46
47
  _SENTINEL_PUBLIC_ROUTES = object()
47
48
  _SENTINEL_DESIGN_PACKS = object()
49
+ _SENTINEL_AUDIT_LINKS = object()
48
50
  _SENTINEL_I18N = object()
49
51
  _SENTINEL_INERTIA = object()
50
52
 
@@ -61,6 +63,7 @@ def _make_services() -> Services:
61
63
  health_registry=_SENTINEL_HEALTH, # type: ignore[arg-type]
62
64
  public_routes=_SENTINEL_PUBLIC_ROUTES, # type: ignore[arg-type]
63
65
  design_packs=_SENTINEL_DESIGN_PACKS, # type: ignore[arg-type]
66
+ audit_links=_SENTINEL_AUDIT_LINKS, # type: ignore[arg-type]
64
67
  i18n_registry=_SENTINEL_I18N, # type: ignore[arg-type]
65
68
  inertia_config=_SENTINEL_INERTIA, # type: ignore[arg-type]
66
69
  modules=(),
@@ -1,69 +0,0 @@
1
- """Minimal ``.env`` parser + env-var helpers — dependency-free.
2
-
3
- Used in places that can't or shouldn't pull in ``pydantic-settings`` (the
4
- diagnostics CLI runs before the host package is imported; the users-module
5
- bootstrap runs after settings are constructed and needs to read values that
6
- ``UsersSettings`` deliberately omits from ``env_file``).
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import os
12
- from pathlib import Path
13
-
14
- BOOL_LITERALS_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
15
- BOOL_LITERALS_FALSE = frozenset({"0", "false", "f", "no", "n", "off"})
16
-
17
-
18
- def parse_dotenv(path: Path | None = None) -> dict[str, str]:
19
- """Parse a ``.env`` file into a dict. Empty dict if the file is missing.
20
-
21
- Values surrounded by matching single or double quotes have the quotes
22
- stripped. Does *not* handle escapes, ``export KEY=…``, or multiline
23
- values — keep the file simple. Does *not* mutate ``os.environ``; the
24
- caller decides whether to merge.
25
-
26
- Without ``path``, looks up ``$SM_PROJECT_ROOT/.env`` (falling back to
27
- ``$CWD/.env``) — the convention used by every tool in this repo.
28
- """
29
- if path is None:
30
- root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd())
31
- path = root / ".env"
32
- if not path.is_file():
33
- return {}
34
- parsed: dict[str, str] = {}
35
- for raw in path.read_text(encoding="utf-8").splitlines():
36
- line = raw.strip()
37
- if not line or line.startswith("#") or "=" not in line:
38
- continue
39
- key, _, value = line.partition("=")
40
- parsed[key.strip()] = value.strip().strip('"').strip("'")
41
- return parsed
42
-
43
-
44
- def load_dotenv_into_environ(path: Path | None = None) -> None:
45
- """Merge ``parse_dotenv(path)`` into ``os.environ`` via ``setdefault``.
46
-
47
- Same precedence as the web process under uvicorn: real environment wins
48
- over file values. Worker entrypoints call this before importing settings.
49
- """
50
- for key, value in parse_dotenv(path).items():
51
- os.environ.setdefault(key, value)
52
-
53
-
54
- def env_str(name: str, default: str) -> str:
55
- """Return ``$name`` if set and non-empty, else ``default``."""
56
- value = os.environ.get(name, "").strip()
57
- return value or default
58
-
59
-
60
- def env_bool(name: str, default: bool = False) -> bool:
61
- """Parse ``$name`` as a boolean, returning ``default`` when unset/blank."""
62
- raw = os.environ.get(name, "").strip().lower()
63
- if not raw:
64
- return default
65
- if raw in BOOL_LITERALS_TRUE:
66
- return True
67
- if raw in BOOL_LITERALS_FALSE:
68
- return False
69
- return default
@@ -1,46 +0,0 @@
1
- """Health check registry for module-contributed health checks."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Awaitable, Callable
6
- from dataclasses import dataclass
7
- from enum import StrEnum
8
-
9
-
10
- class HealthStatus(StrEnum):
11
- HEALTHY = "healthy"
12
- DEGRADED = "degraded"
13
- UNHEALTHY = "unhealthy"
14
-
15
-
16
- @dataclass
17
- class HealthCheckResult:
18
- """Result of a single health check."""
19
-
20
- status: HealthStatus
21
- detail: str | None = None
22
-
23
-
24
- HealthCheckFn = Callable[[], Awaitable[HealthCheckResult]]
25
-
26
-
27
- @dataclass
28
- class HealthCheck:
29
- """A named health check with an async callable."""
30
-
31
- name: str
32
- check: HealthCheckFn
33
-
34
-
35
- class HealthRegistry:
36
- """Collects health checks contributed by modules."""
37
-
38
- def __init__(self) -> None:
39
- self._checks: list[HealthCheck] = []
40
-
41
- def add(self, check: HealthCheck) -> None:
42
- self._checks.append(check)
43
-
44
- @property
45
- def all_checks(self) -> list[HealthCheck]:
46
- return list(self._checks)