pillarz 0.1.0.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pillarz/__init__.py +309 -0
- pillarz/__main__.py +176 -0
- pillarz/_auth/__init__.py +2 -0
- pillarz/_auth/passwords.py +74 -0
- pillarz/_auth/setup.py +57 -0
- pillarz/_db/__init__.py +1 -0
- pillarz/_db/clock.py +67 -0
- pillarz/_db/connection.py +241 -0
- pillarz/_db/m0001_initial.py +194 -0
- pillarz/_db/migrations.py +272 -0
- pillarz/_i18n/__init__.py +11 -0
- pillarz/_i18n/babel-js.cfg +4 -0
- pillarz/_i18n/babel.cfg +8 -0
- pillarz/_i18n/catalogs.py +216 -0
- pillarz/_i18n/jinja.py +41 -0
- pillarz/_i18n/js.py +43 -0
- pillarz/_i18n/request.py +103 -0
- pillarz/_i18n/tools.py +200 -0
- pillarz/_protocol.py +315 -0
- pillarz/_runtime.py +86 -0
- pillarz/_themes.py +154 -0
- pillarz/_web/__init__.py +1 -0
- pillarz/_web/app.py +69 -0
- pillarz/_web/context.py +14 -0
- pillarz/_web/csp.py +148 -0
- pillarz/_web/errors.py +132 -0
- pillarz/_web/gate.py +134 -0
- pillarz/_web/guard.py +241 -0
- pillarz/_web/headers.py +62 -0
- pillarz/_web/net.py +48 -0
- pillarz/_web/pages.py +30 -0
- pillarz/_web/render.py +271 -0
- pillarz/_web/static.py +40 -0
- pillarz/config.py +336 -0
- pillarz/db.py +337 -0
- pillarz/i18n.py +126 -0
- pillarz/locale/en/LC_MESSAGES/pillarz-js.po +86 -0
- pillarz/locale/en/LC_MESSAGES/pillarz.po +622 -0
- pillarz/locale/pillarz-js.pot +83 -0
- pillarz/locale/pillarz.pot +588 -0
- pillarz/static/pillarz/css/base.css +531 -0
- pillarz/static/pillarz/favicon.svg +1 -0
- pillarz/static/pillarz/js/api.js +96 -0
- pillarz/static/pillarz/js/i18n.js +158 -0
- pillarz/static/pillarz/js/ui.js +433 -0
- pillarz/static/pillarz/vendor/lucide/LICENSE +43 -0
- pillarz/templates/pillarz/bare.html +25 -0
- pillarz/templates/pillarz/base.html +38 -0
- pillarz/templates/pillarz/error.html +14 -0
- pillarz/templates/pillarz/icons.svg +66 -0
- pillarz/templates/pillarz/partials/flash.html +8 -0
- pillarz/templates/pillarz/partials/nav.html +30 -0
- pillarz/templates/pillarz/plain.html +16 -0
- pillarz/testing/__init__.py +149 -0
- pillarz/testing/_web.py +112 -0
- pillarz/text.py +81 -0
- pillarz/web.py +182 -0
- pillarz-0.1.0.dev1.dist-info/METADATA +71 -0
- pillarz-0.1.0.dev1.dist-info/RECORD +62 -0
- pillarz-0.1.0.dev1.dist-info/WHEEL +5 -0
- pillarz-0.1.0.dev1.dist-info/licenses/LICENSE +661 -0
- pillarz-0.1.0.dev1.dist-info/top_level.txt +1 -0
pillarz/__init__.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""pillarz – gemeinsames Fundament für FastAPI-Webservices (ADR 0001).
|
|
2
|
+
|
|
3
|
+
Eine App erzeugt **genau ein** `Pillarz`-Objekt je Prozess, meldet daran ihre Erweiterungen an und baut
|
|
4
|
+
zum Schluss mit `create_app()` die FastAPI-Instanz::
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from pillarz import Pillarz
|
|
8
|
+
from . import __version__, migrations
|
|
9
|
+
|
|
10
|
+
pz = Pillarz(name="labelz", title="Labelz", version=__version__,
|
|
11
|
+
package_dir=Path(__file__).resolve().parent, migrations=migrations.STEPS)
|
|
12
|
+
app = pz.create_app()
|
|
13
|
+
|
|
14
|
+
Der Import von pillarz hat keine Nebenwirkungen: Konfiguration, Datenverzeichnis und Schlüssel
|
|
15
|
+
entstehen erst beim Erzeugen von `Pillarz(...)` (`Config.from_env()`), die Datenbank erst im
|
|
16
|
+
Lifespan bzw. bei `python -m pillarz migrate`.
|
|
17
|
+
"""
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from . import _runtime
|
|
26
|
+
from .config import Config
|
|
27
|
+
from .i18n import lazy
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0.dev1"
|
|
30
|
+
|
|
31
|
+
__all__ = ["Pillarz", "NavItem", "AdminTab", "AccountSection", "DeleteInput", "Setting", "Theme"]
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("pillarz.system")
|
|
34
|
+
|
|
35
|
+
_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
|
|
36
|
+
_KEY_RE = re.compile(r"^[a-z][a-z0-9_-]{0,39}$")
|
|
37
|
+
DEFAULT_AUTH_METHODS = ("password", "oidc")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------ Datenklassen der Registrierungen
|
|
41
|
+
# Vorerst nur die Hüllen (Meilensteinplan 0.1, Paket A): der Schlüssel steht fest, die übrigen Felder
|
|
42
|
+
# kommen mit den Paketen, die sie nutzen (Architektur §3.3–§3.8).
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class NavItem:
|
|
45
|
+
"""Navigationseintrag der App (Architektur §3.3), nur für Angemeldete sichtbar::
|
|
46
|
+
|
|
47
|
+
pz.nav(NavItem("library", "/", _l("Bibliothek"), icon="library", order=10))
|
|
48
|
+
|
|
49
|
+
- `icon`: Name eines Symbols `i-<icon>` im Sprite (pillarz' `icons.svg` oder `pz.icons()`).
|
|
50
|
+
- `visible(request, user) -> bool`: Eintrag nur zeigen, wenn wahr.
|
|
51
|
+
- `badge(request, user) -> int`: Zahl am Eintrag (nur auf Seiten mit Navigation berechnet; 0 = keine),
|
|
52
|
+
`badge_href` Ziel des Badges (Standard `href`), `badge_label(n) -> str` zugänglicher Name.
|
|
53
|
+
"""
|
|
54
|
+
key: str
|
|
55
|
+
href: str = "/"
|
|
56
|
+
label: object = ""
|
|
57
|
+
icon: str | None = None
|
|
58
|
+
order: int = 100
|
|
59
|
+
visible: Callable | None = None
|
|
60
|
+
badge: Callable | None = None
|
|
61
|
+
badge_href: str | None = None
|
|
62
|
+
badge_label: Callable | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class AdminTab:
|
|
67
|
+
"""Eigener Admin-Reiter (Architektur §3.3). TODO(Paket E): label, order, icon, template, module."""
|
|
68
|
+
key: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class AccountSection:
|
|
73
|
+
"""Eigener Abschnitt der Kontoseite (Architektur §3.3). TODO(Paket E): label, order, template, module."""
|
|
74
|
+
key: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class DeleteInput:
|
|
79
|
+
"""Zusatzangabe im Löschdialog (Architektur §3.4, ADR 0006). TODO(Paket E): kind, label, default …"""
|
|
80
|
+
key: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Setting:
|
|
85
|
+
"""Registrierte App-Einstellung (Architektur §3.8). TODO(Paket E): default, kind, validate, label, ui."""
|
|
86
|
+
key: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Theme:
|
|
91
|
+
"""Weiteres eingebautes Theme (Architektur §14.5): vier Grundfarben je Modus als `#rrggbb`::
|
|
92
|
+
|
|
93
|
+
pz.theme(Theme("etikett", _l("Etikett"),
|
|
94
|
+
light={"bg": "#…", "surface": "#…", "ink": "#…", "accent": "#…"}, dark={…}))
|
|
95
|
+
"""
|
|
96
|
+
id: str
|
|
97
|
+
name: object = ""
|
|
98
|
+
light: dict = field(default_factory=dict)
|
|
99
|
+
dark: dict = field(default_factory=dict)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------ das App-Objekt
|
|
103
|
+
class Pillarz:
|
|
104
|
+
"""Das eine pillarz-Objekt einer App.
|
|
105
|
+
|
|
106
|
+
- `name`: Kennung (Cookie, Logger, Standard-Dateiname der SQLite-Datenbank `DATA_DIR/<name>.db`),
|
|
107
|
+
klein, `[a-z][a-z0-9_-]*`, höchstens 32 Zeichen; `pillarz` ist reserviert.
|
|
108
|
+
- `title`: Standard-Seitenname, `version`: Version der App.
|
|
109
|
+
- `package_dir`: Verzeichnis der App mit `templates/`, `static/`, `locale/` (je optional).
|
|
110
|
+
- `migrations`: geordnete App-Migrationen `[("0001_initial", step), …]`, `step(conn)` (ADR 0003).
|
|
111
|
+
- `auth_methods`: Vorgabe für die Anmeldemethoden; `AUTH_METHODS` in der Umgebung überschreibt.
|
|
112
|
+
- `weak_passwords`: ergänzt pillarz' Liste verbotener Platzhalter-Passwörter.
|
|
113
|
+
- `environ`: nur für Tests – statt `os.environ`.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, *, name: str, title: str | None = None, version: str = "0", package_dir=None,
|
|
117
|
+
migrations=(), auth_methods=DEFAULT_AUTH_METHODS, weak_passwords=(), environ=None):
|
|
118
|
+
# TODO(Pakete D–F, 0.2): changelog, self_delete und weitere Parameter aus Architektur §3.1
|
|
119
|
+
from ._db.migrations import validate
|
|
120
|
+
_runtime.check_free()
|
|
121
|
+
if not isinstance(name, str) or not _NAME_RE.fullmatch(name) or name == "pillarz":
|
|
122
|
+
raise ValueError(f"Pillarz(name=…): {name!r} ist keine gültige Kennung "
|
|
123
|
+
"(klein, [a-z][a-z0-9_-]*, höchstens 32 Zeichen, nicht 'pillarz')")
|
|
124
|
+
self.name = name
|
|
125
|
+
self.title = title or name
|
|
126
|
+
self.version = str(version)
|
|
127
|
+
self.package_dir = Path(package_dir).resolve() if package_dir is not None else None
|
|
128
|
+
self.migrations = validate(migrations, "app")
|
|
129
|
+
self.weak_passwords = frozenset(w.strip().lower() for w in weak_passwords if w and w.strip())
|
|
130
|
+
self._registry = _runtime.Registry()
|
|
131
|
+
self._app = None
|
|
132
|
+
self._web = None # _web.app.WebState, ab create_app()
|
|
133
|
+
self.config: Config = Config.from_env(environ, name=name, auth_methods=tuple(auth_methods))
|
|
134
|
+
_runtime.install(self)
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
return f"<Pillarz {self.name!r} {self.version}>"
|
|
138
|
+
|
|
139
|
+
# -------------------------------------------------------------- Registrierungen
|
|
140
|
+
def on_startup(self, fn: Callable[[], None]) -> Callable[[], None]:
|
|
141
|
+
"""Funktion nach den Migrationen und vor dem ersten Request aufrufen (als Dekorator nutzbar)."""
|
|
142
|
+
self._registry.add("startup", fn)
|
|
143
|
+
return fn
|
|
144
|
+
|
|
145
|
+
def on_shutdown(self, fn: Callable[[], None]) -> Callable[[], None]:
|
|
146
|
+
"""Funktion beim Herunterfahren aufrufen (umgekehrte Reihenfolge der Anmeldung)."""
|
|
147
|
+
self._registry.add("shutdown", fn)
|
|
148
|
+
return fn
|
|
149
|
+
|
|
150
|
+
def nav(self, item: NavItem) -> NavItem:
|
|
151
|
+
"""Navigationseintrag anmelden (Architektur §3.3). pillarz hängt rechts Konto, Admin und Abmelden an."""
|
|
152
|
+
if not isinstance(item, NavItem):
|
|
153
|
+
raise TypeError("pz.nav() erwartet ein NavItem")
|
|
154
|
+
if not _KEY_RE.fullmatch(item.key) or any(i.key == item.key for i in self._registry.items("nav")):
|
|
155
|
+
raise ValueError(f"pz.nav(): Schlüssel {item.key!r} ist ungültig oder schon vergeben")
|
|
156
|
+
if not isinstance(item.href, str) or not item.href.startswith("/") or item.href.startswith("//"):
|
|
157
|
+
raise ValueError(f"pz.nav({item.key!r}): href muss ein Pfad dieser Seite sein (/…)")
|
|
158
|
+
self._registry.add("nav", item)
|
|
159
|
+
return item
|
|
160
|
+
|
|
161
|
+
def csp(self, **directives) -> None:
|
|
162
|
+
"""CSP der App ergänzen, z. B. `pz.csp(img_src=["blob:"], worker_src=["'self'"])`. Lockern ist
|
|
163
|
+
unmöglich: `'unsafe-*'` (außer `'wasm-unsafe-eval'`), `*`, reine Schemata in `script-src`, `javascript:`,
|
|
164
|
+
in `worker-src`/`child-src` mehr als `'self'`/`blob:`, Hashes in `style-src`, bei reinem HTTPS
|
|
165
|
+
`http://`/`ws://`-Hosts und jede Änderung an `object-src`/`base-uri`/`frame-ancestors` →
|
|
166
|
+
`ValueError` (Startabbruch, ADR 0004 §5)."""
|
|
167
|
+
from ._web.csp import check
|
|
168
|
+
self._registry.add("csp", check(directives, https_only=self.config.https_only))
|
|
169
|
+
|
|
170
|
+
def upload_limit(self, path, size: int) -> None:
|
|
171
|
+
"""Größere Body-Grenze für einen Upload-Pfad (nur POST, `Content-Length` Pflicht): exakter Pfad,
|
|
172
|
+
Präfix mit `/` am Ende oder `re.compile(…)` (ganzer Pfad). Sonst gilt 1 MiB."""
|
|
173
|
+
from ._web.headers import PathRule
|
|
174
|
+
if not isinstance(size, int) or size <= 0:
|
|
175
|
+
raise ValueError("pz.upload_limit(): Größe in Bytes > 0 erwartet")
|
|
176
|
+
PathRule(path)
|
|
177
|
+
self._registry.add("upload_limit", (path, size))
|
|
178
|
+
|
|
179
|
+
def no_store(self, path) -> None:
|
|
180
|
+
"""Antworten auf diesen Pfad (Präfix mit `/` am Ende, exakter Pfad oder `re.compile`) nie
|
|
181
|
+
zwischenspeichern, nie als Referer weitergeben, nicht indexieren – für Link-/Token-Seiten."""
|
|
182
|
+
from ._web.headers import PathRule
|
|
183
|
+
PathRule(path)
|
|
184
|
+
self._registry.add("no_store", path)
|
|
185
|
+
|
|
186
|
+
def public_mount(self, path: str) -> None:
|
|
187
|
+
"""Eine eingehängte Unter-App (`app.mount(path, …)`) ohne Anmeldung erreichbar machen.
|
|
188
|
+
|
|
189
|
+
Mounts, Starlette-Routen (`app.add_route`) und `Host`-Routen laufen am Gate vorbei (sie kennen
|
|
190
|
+
keine FastAPI-Dependencies). pillarz behandelt sie deshalb als **geschützt**: der Guard weist
|
|
191
|
+
anonyme Anfragen an sie ab (401 bzw. 303 auf `/login`), `testing.route_audit()` meldet sie.
|
|
192
|
+
Nur so freigegebene Mount-Pfade (genau der Pfad aus `app.mount`, z. B. `"/docs-static"`) und
|
|
193
|
+
pillarz' eigene Static-Mounts sind öffentlich (ADR 0004 §2)."""
|
|
194
|
+
if not isinstance(path, str) or not path.startswith("/") or path.startswith("//"):
|
|
195
|
+
raise ValueError(f"pz.public_mount(): {path!r} ist kein Pfad (/…)")
|
|
196
|
+
self._registry.add("public_mount", path.rstrip("/") or "/")
|
|
197
|
+
|
|
198
|
+
def boot(self, fn: Callable) -> Callable:
|
|
199
|
+
"""`fn(request, user) -> dict` ergänzt `boot.app` im `#boot`-Block (nur für Angemeldete)."""
|
|
200
|
+
self._registry.add("boot", fn)
|
|
201
|
+
return fn
|
|
202
|
+
|
|
203
|
+
def log_source(self, logger: str, source: str, label) -> None:
|
|
204
|
+
"""Logger der App einem Protokoll-Bereich zuordnen, z. B.
|
|
205
|
+
`pz.log_source("labelz.direct", "direct", _l("Direktdruck"))`. Nur Logger unter `<name>`."""
|
|
206
|
+
if not (logger == self.name or logger.startswith(self.name + ".")):
|
|
207
|
+
raise ValueError(f"pz.log_source(): Logger {logger!r} gehört nicht zur App {self.name!r}")
|
|
208
|
+
if not _KEY_RE.fullmatch(source):
|
|
209
|
+
raise ValueError(f"pz.log_source(): Bereich {source!r} ist ungültig ([a-z][a-z0-9_-]*)")
|
|
210
|
+
self._registry.add("log_source", (logger, source, label))
|
|
211
|
+
|
|
212
|
+
def theme(self, theme: Theme) -> Theme:
|
|
213
|
+
"""Weiteres eingebautes Theme anmelden (nach pillarz' fünf, in Anmeldereihenfolge)."""
|
|
214
|
+
from ._themes import check_registration
|
|
215
|
+
if not isinstance(theme, Theme):
|
|
216
|
+
raise TypeError("pz.theme() erwartet ein Theme")
|
|
217
|
+
check_registration(theme)
|
|
218
|
+
if any(t.id == theme.id for t in self._registry.items("theme")):
|
|
219
|
+
raise ValueError(f"pz.theme(): {theme.id!r} ist schon angemeldet")
|
|
220
|
+
self._registry.add("theme", theme)
|
|
221
|
+
return theme
|
|
222
|
+
|
|
223
|
+
def icons(self, path) -> None:
|
|
224
|
+
"""Weitere `<symbol id="i-…">` aus einer SVG-Datei ins Sprite jeder Seite aufnehmen."""
|
|
225
|
+
path = Path(path)
|
|
226
|
+
if not path.is_file():
|
|
227
|
+
raise ValueError(f"pz.icons(): {path} gibt es nicht")
|
|
228
|
+
self._registry.add("icons", path)
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def frozen(self) -> bool:
|
|
232
|
+
return self._registry.frozen
|
|
233
|
+
|
|
234
|
+
# -------------------------------------------------------------- Start
|
|
235
|
+
def migrate(self, *, dry_run: bool = False) -> list[str]:
|
|
236
|
+
"""Offene Migrationen (pillarz, dann App) ausführen – wie beim Start. Rückgabe: angewendete
|
|
237
|
+
Schritte als `"komponente/name"`."""
|
|
238
|
+
from ._db.migrations import run
|
|
239
|
+
return run(self, dry_run=dry_run)
|
|
240
|
+
|
|
241
|
+
def create_app(self):
|
|
242
|
+
"""FastAPI-Instanz bauen: Guard, Gate „default deny“, Fehler-Handler, Static, Templates,
|
|
243
|
+
`/healthz`, `/themes.css` (Paket B). Danach sind alle Registrierungen eingefroren."""
|
|
244
|
+
from ._web.app import build
|
|
245
|
+
|
|
246
|
+
if self._app is not None:
|
|
247
|
+
return self._app
|
|
248
|
+
self._registry.freeze()
|
|
249
|
+
pz = self
|
|
250
|
+
|
|
251
|
+
@asynccontextmanager
|
|
252
|
+
async def lifespan(_app):
|
|
253
|
+
from starlette.concurrency import run_in_threadpool
|
|
254
|
+
for warning in pz.config.warnings:
|
|
255
|
+
log.warning(warning)
|
|
256
|
+
await run_in_threadpool(pz._startup)
|
|
257
|
+
try:
|
|
258
|
+
yield
|
|
259
|
+
finally:
|
|
260
|
+
await run_in_threadpool(pz._shutdown)
|
|
261
|
+
|
|
262
|
+
self._app = build(self, lifespan)
|
|
263
|
+
return self._app
|
|
264
|
+
|
|
265
|
+
def _startup(self) -> None:
|
|
266
|
+
from . import _protocol
|
|
267
|
+
from ._i18n import catalogs
|
|
268
|
+
catalogs.load(self) # kaputte .po stoppen den Start (statt still deutsch anzuzeigen)
|
|
269
|
+
self.migrate()
|
|
270
|
+
_protocol.install(self) # nach den Migrationen (Tabelle pz_log), vor allem anderen
|
|
271
|
+
urls = self.config.public_urls
|
|
272
|
+
if not urls:
|
|
273
|
+
log.warning(lazy("PUBLIC_URLS ist nicht gesetzt – kein HSTS, Anmelde-Cookie ohne Secure-Flag und keine "
|
|
274
|
+
"Links nach außen. Für den Betrieb die öffentliche https://-Adresse setzen."))
|
|
275
|
+
elif not self.config.https_only and any(u.startswith("https://") for u in urls):
|
|
276
|
+
log.warning(lazy("PUBLIC_URLS mischt http:// und https:// – deshalb kein HSTS und das Anmelde-Cookie "
|
|
277
|
+
"wird auch über unverschlüsseltes HTTP gesendet."))
|
|
278
|
+
# TODO(Paket D): Admin aus ADMIN_USERNAME/ADMIN_PASSWORD anlegen, Startwarnung Einrichtung offen
|
|
279
|
+
self._check_admin_reachable()
|
|
280
|
+
for fn in self._registry.items("startup"):
|
|
281
|
+
fn()
|
|
282
|
+
|
|
283
|
+
def _check_admin_reachable(self) -> None:
|
|
284
|
+
"""C-L4: Konten, aber kein aktives Admin-Konto und keine `OIDC_ADMIN_GROUPS` – niemand kommt in
|
|
285
|
+
die Verwaltung (typisch nach der Übernahme einer Datenbank ohne Umbau `is_admin` → `role`).
|
|
286
|
+
Kein Startabbruch, aber ein Fehler im Protokoll. Leere Datenbank = Einrichtung, kein Fehler."""
|
|
287
|
+
from .db import connect
|
|
288
|
+
if self.config.oidc_admin_groups:
|
|
289
|
+
return
|
|
290
|
+
with connect() as conn:
|
|
291
|
+
users = conn.scalar("SELECT COUNT(*) FROM users")
|
|
292
|
+
admins = conn.scalar("SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1")
|
|
293
|
+
if users and not admins:
|
|
294
|
+
log.error(lazy("Kein aktives Admin-Konto (Konten insgesamt: %(users)d) und OIDC_ADMIN_GROUPS ist leer "
|
|
295
|
+
"– niemand kann die Verwaltung öffnen. Ein Konto zum Admin machen (role = 'admin') "
|
|
296
|
+
"oder OIDC_ADMIN_GROUPS setzen."), {"users": users})
|
|
297
|
+
|
|
298
|
+
def _shutdown(self) -> None:
|
|
299
|
+
from ._db.connection import close_pool
|
|
300
|
+
try:
|
|
301
|
+
for fn in reversed(self._registry.items("shutdown")):
|
|
302
|
+
try:
|
|
303
|
+
fn()
|
|
304
|
+
except Exception: # noqa: BLE001 – weitere Handler und das Schließen des Pools laufen trotzdem
|
|
305
|
+
log.exception(lazy("on_shutdown-Handler %r ist fehlgeschlagen"), fn)
|
|
306
|
+
finally:
|
|
307
|
+
from . import _protocol
|
|
308
|
+
_protocol.uninstall()
|
|
309
|
+
close_pool()
|
pillarz/__main__.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Kommandozeile: `python -m pillarz <befehl> <modul:objekt>`.
|
|
2
|
+
|
|
3
|
+
python -m pillarz migrate examples.minimal.app:pz # offene Migrationen ausführen
|
|
4
|
+
python -m pillarz migrate --dry-run examples.minimal.app:pz # nur anzeigen (Rollback am Ende)
|
|
5
|
+
python -m pillarz migrate --status examples.minimal.app:pz # Stand je Schritt
|
|
6
|
+
python -m pillarz create-admin examples.minimal.app:pz # erstes Admin-Konto (nur leere DB)
|
|
7
|
+
|
|
8
|
+
`<modul:objekt>` zeigt auf die `Pillarz`-Instanz der App (oder die FastAPI-App, die `create_app()`
|
|
9
|
+
geliefert hat). Exit-Code 0 = gut, 1 = Fehler, 2 = Aufruf falsch.
|
|
10
|
+
"""
|
|
11
|
+
# TODO(0.5): worker, TODO(Paket B/H): check
|
|
12
|
+
import argparse
|
|
13
|
+
import getpass
|
|
14
|
+
import importlib
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from .i18n import _
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load(target: str):
|
|
21
|
+
from . import Pillarz, _runtime
|
|
22
|
+
module_name, _sep, attr = target.partition(":")
|
|
23
|
+
if not module_name:
|
|
24
|
+
raise SystemExit(_("Ziel muss wie 'paket.modul:pz' aussehen, nicht %(t)r.") % {"t": target})
|
|
25
|
+
module = importlib.import_module(module_name)
|
|
26
|
+
obj = getattr(module, attr or "pz", None)
|
|
27
|
+
if isinstance(obj, Pillarz):
|
|
28
|
+
return obj
|
|
29
|
+
try:
|
|
30
|
+
pz = _runtime.current()
|
|
31
|
+
except _runtime.NoInstanceError:
|
|
32
|
+
pz = None
|
|
33
|
+
if obj is not None and pz is not None and obj is pz._app: # Ziel ist die FastAPI-App
|
|
34
|
+
return pz
|
|
35
|
+
raise SystemExit(_("%(t)s ist keine Pillarz-Instanz (und keine App aus create_app()).") % {"t": target})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _db_label(pz) -> str:
|
|
39
|
+
cfg = pz.config
|
|
40
|
+
return f"PostgreSQL {cfg.postgres_db}" if cfg.postgres else f"SQLite {cfg.db_path}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_migrate(args) -> int:
|
|
44
|
+
from ._db.migrations import MigrationError, _plan, _sqlite_missing, status
|
|
45
|
+
pz = _load(args.target)
|
|
46
|
+
if (args.status or args.dry_run) and _sqlite_missing(pz):
|
|
47
|
+
# Nur lesen heißt: keine leere SQLite-Datei anlegen (C-L5)
|
|
48
|
+
steps = [f"{component}/{name}" for component, name, _fn in _plan(pz)]
|
|
49
|
+
if args.status:
|
|
50
|
+
print(_("Die SQLite-Datenbank %(path)s gibt es noch nicht – alle Migrationen sind offen:")
|
|
51
|
+
% {"path": pz.config.db_path})
|
|
52
|
+
for step in steps:
|
|
53
|
+
print(f" {step}: " + _("offen"))
|
|
54
|
+
else:
|
|
55
|
+
print(_("Trockenlauf – die SQLite-Datenbank %(path)s gibt es noch nicht (nichts angelegt, nichts "
|
|
56
|
+
"ausprobiert):") % {"path": pz.config.db_path})
|
|
57
|
+
for step in steps:
|
|
58
|
+
print(_(" würde ausführen: %(s)s") % {"s": step})
|
|
59
|
+
return 0
|
|
60
|
+
try:
|
|
61
|
+
if args.status:
|
|
62
|
+
rows = status(pz)
|
|
63
|
+
print(_("Migrationen von %(app)s (%(db)s):") % {"app": pz.name, "db": _db_label(pz)})
|
|
64
|
+
for row in rows:
|
|
65
|
+
state = (row["applied_at"] if row["known"] else _("unbekannt (neuere Version?) – %(at)s")
|
|
66
|
+
% {"at": row["applied_at"]}) if row["applied_at"] else _("offen")
|
|
67
|
+
print(f" {row['component']}/{row['name']}: {state}")
|
|
68
|
+
return 1 if any(not r["known"] for r in rows) else 0
|
|
69
|
+
done = pz.migrate(dry_run=args.dry_run)
|
|
70
|
+
except MigrationError as exc:
|
|
71
|
+
print(_("Fehler: %(e)s") % {"e": exc}, file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
if args.dry_run:
|
|
74
|
+
print(_("Trockenlauf – nichts geändert (%(db)s):") % {"db": _db_label(pz)})
|
|
75
|
+
lines = [_(" würde ausführen: %(s)s") % {"s": step} for step in done]
|
|
76
|
+
else:
|
|
77
|
+
print(_("Ausgeführt (%(db)s):") % {"db": _db_label(pz)})
|
|
78
|
+
lines = [_(" angewendet: %(s)s") % {"s": step} for step in done]
|
|
79
|
+
for line in lines or [_(" keine offenen Migrationen")]:
|
|
80
|
+
print(line)
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _ask(prompt: str) -> str:
|
|
85
|
+
print(prompt, end="", flush=True)
|
|
86
|
+
line = sys.stdin.readline()
|
|
87
|
+
if not line:
|
|
88
|
+
raise SystemExit(_("Abgebrochen."))
|
|
89
|
+
return line.rstrip("\n")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _ask_secret(prompt: str) -> str:
|
|
93
|
+
if sys.stdin.isatty():
|
|
94
|
+
return getpass.getpass(prompt)
|
|
95
|
+
return _ask(prompt) # Eingabe aus einer Pipe (Skripte, Tests)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_create_admin(args) -> int:
|
|
99
|
+
from ._auth.passwords import hash_password, password_problem
|
|
100
|
+
from ._auth.setup import EMAIL_RE, NAME_MAX, USERNAME_RE, SetupClosed, create_first_admin, setup_open
|
|
101
|
+
from .db import connect
|
|
102
|
+
pz = _load(args.target)
|
|
103
|
+
if "password" not in pz.config.auth_methods:
|
|
104
|
+
print(_("Die Anmeldemethode password ist abgeschaltet – ein Konto mit Passwort ergibt keinen Sinn. "
|
|
105
|
+
"Admin-Rechte kommen dann aus OIDC_ADMIN_GROUPS."), file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
from ._db.migrations import MigrationError
|
|
108
|
+
try:
|
|
109
|
+
pz.migrate()
|
|
110
|
+
except MigrationError as exc:
|
|
111
|
+
print(_("Fehler: %(e)s") % {"e": exc}, file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
with connect() as conn:
|
|
114
|
+
is_open = setup_open(conn)
|
|
115
|
+
if not is_open:
|
|
116
|
+
print(_("Es gibt schon Konten – create-admin geht nur bei leerer Datenbank. Weitere Konten legt "
|
|
117
|
+
"ein Admin in der Weboberfläche an."), file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
print(_("Erstes Admin-Konto für %(app)s (%(db)s) anlegen.") % {"app": pz.title, "db": _db_label(pz)})
|
|
120
|
+
while True:
|
|
121
|
+
username = _ask(_("Benutzername: ")).strip()
|
|
122
|
+
if USERNAME_RE.fullmatch(username):
|
|
123
|
+
break
|
|
124
|
+
print(_("2–40 Zeichen: Buchstaben, Ziffern, Punkt, Bindestrich, Unterstrich; zuerst ein Buchstabe "
|
|
125
|
+
"oder eine Ziffer."))
|
|
126
|
+
while True:
|
|
127
|
+
name = _ask(_("Anzeigename [%(u)s]: ") % {"u": username}).strip() or username
|
|
128
|
+
if len(name) <= NAME_MAX:
|
|
129
|
+
break
|
|
130
|
+
print(_("Höchstens %(n)d Zeichen.") % {"n": NAME_MAX})
|
|
131
|
+
while True:
|
|
132
|
+
email = _ask(_("E-Mail (optional): ")).strip().lower()
|
|
133
|
+
if not email or EMAIL_RE.fullmatch(email):
|
|
134
|
+
break
|
|
135
|
+
print(_("Das ist keine E-Mail-Adresse."))
|
|
136
|
+
while True:
|
|
137
|
+
password = _ask_secret(_("Passwort: "))
|
|
138
|
+
problem = password_problem(password, username, pz.weak_passwords)
|
|
139
|
+
if problem:
|
|
140
|
+
print(problem)
|
|
141
|
+
continue
|
|
142
|
+
if _ask_secret(_("Passwort wiederholen: ")) != password:
|
|
143
|
+
print(_("Die Passwörter stimmen nicht überein."))
|
|
144
|
+
continue
|
|
145
|
+
break
|
|
146
|
+
try:
|
|
147
|
+
create_first_admin(username, name, hash_password(password), email=email or None, source="cli")
|
|
148
|
+
except SetupClosed as exc:
|
|
149
|
+
print(str(exc), file=sys.stderr)
|
|
150
|
+
return 1
|
|
151
|
+
print(_("Admin-Konto %(u)s angelegt.") % {"u": username})
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main(argv: list[str] | None = None) -> int:
|
|
156
|
+
parser = argparse.ArgumentParser(prog="python -m pillarz", description=_("Werkzeuge für pillarz-Apps."))
|
|
157
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
158
|
+
|
|
159
|
+
p = sub.add_parser("migrate", help=_("Migrationen ausführen, anzeigen oder Stand abfragen"))
|
|
160
|
+
mode = p.add_mutually_exclusive_group()
|
|
161
|
+
mode.add_argument("--dry-run", action="store_true",
|
|
162
|
+
help=_("nur anzeigen, was liefe – alles in einer Transaktion, am Ende Rollback"))
|
|
163
|
+
mode.add_argument("--status", action="store_true", help=_("Stand je Migration anzeigen"))
|
|
164
|
+
p.add_argument("target", help=_("Pillarz-Instanz der App als modul:objekt, z. B. app.main:pz"))
|
|
165
|
+
p.set_defaults(func=cmd_migrate)
|
|
166
|
+
|
|
167
|
+
p = sub.add_parser("create-admin", help=_("erstes Admin-Konto anlegen (nur bei leerer Datenbank)"))
|
|
168
|
+
p.add_argument("target", help=_("Pillarz-Instanz der App als modul:objekt, z. B. app.main:pz"))
|
|
169
|
+
p.set_defaults(func=cmd_create_admin)
|
|
170
|
+
|
|
171
|
+
args = parser.parse_args(argv)
|
|
172
|
+
return args.func(args)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
sys.exit(main())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Passwörter: scrypt-Hash und Pflichtregeln (K-4). Portiert aus labelz `app/security.py`
|
|
2
|
+
(`hash_password`, `verify_password`, `needs_rehash`, `PLACEHOLDERS`).
|
|
3
|
+
|
|
4
|
+
Abweichungen von labelz (Entscheidung K-4 der technischen Leitung): Pflicht 10–200 Zeichen (labelz
|
|
5
|
+
8–200 bzw. 12 für den Env-Admin), nicht gleich dem Benutzernamen, nicht aus der Platzhalterliste
|
|
6
|
+
(inkl. `weak_passwords` der App). Die labelz-eigenen Wörter („labelz“, „etiketten“) stehen nicht mehr
|
|
7
|
+
in der allgemeinen Liste – die App ergänzt sie über `Pillarz(weak_passwords=…)`.
|
|
8
|
+
"""
|
|
9
|
+
# TODO(Paket D): Dummy-Hash gegen Zeitmessung, Rehash bei der Anmeldung, Hinweise über die
|
|
10
|
+
# Pflichtregeln hinaus (Architektur §10.1)
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import secrets
|
|
14
|
+
|
|
15
|
+
from ..i18n import _
|
|
16
|
+
|
|
17
|
+
# N=2**16 braucht 64 MiB je Hash – mehr als hashlib standardmäßig erlaubt (32 MiB), daher maxmem
|
|
18
|
+
_N, _R, _P, _DKLEN = 2 ** 16, 8, 1, 32
|
|
19
|
+
_MAXMEM = 256 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
PASSWORD_MIN, PASSWORD_MAX = 10, 200
|
|
22
|
+
|
|
23
|
+
# Platzhalter aus Anleitungen und Beispielkonfigurationen – damit wird kein Konto angelegt
|
|
24
|
+
PLACEHOLDERS = frozenset({
|
|
25
|
+
"bitte-aendern", "bitte-ändern", "bitte_aendern", "bitteaendern", "admin", "administrator",
|
|
26
|
+
"changeme", "change-me", "change_me", "password", "passwort", "geheim", "secret", "test",
|
|
27
|
+
"test123", "12345678", "123456789", "1234567890", "qwertz", "qwerty", "admin123", "adminadmin",
|
|
28
|
+
"dev-passwort", "pillarz", "password123", "passwort123", "0123456789", "qwertzuiop", "qwertyuiop",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def hash_password(password: str) -> str:
|
|
33
|
+
salt = secrets.token_bytes(16)
|
|
34
|
+
digest = hashlib.scrypt(password.encode(), salt=salt, n=_N, r=_R, p=_P, dklen=_DKLEN, maxmem=_MAXMEM)
|
|
35
|
+
return f"scrypt${_N}${_R}${_P}${salt.hex()}${digest.hex()}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def needs_rehash(stored: str | None) -> bool:
|
|
39
|
+
"""Gespeicherter Hash mit schwächeren Parametern als heute (z. B. N=2**14 aus labelz v0.1.0)?"""
|
|
40
|
+
try:
|
|
41
|
+
kind, n, r, p, _salt, _digest = (stored or "").split("$")
|
|
42
|
+
return kind != "scrypt" or int(n) < _N or int(r) < _R or int(p) < _P
|
|
43
|
+
except ValueError:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verify_password(password: str, stored: str | None) -> bool:
|
|
48
|
+
try:
|
|
49
|
+
kind, n, r, p, salt, digest = (stored or "").split("$")
|
|
50
|
+
if kind != "scrypt":
|
|
51
|
+
return False
|
|
52
|
+
given = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=int(n), r=int(r), p=int(p),
|
|
53
|
+
dklen=len(digest) // 2, maxmem=_MAXMEM)
|
|
54
|
+
except (ValueError, TypeError):
|
|
55
|
+
return False
|
|
56
|
+
return hmac.compare_digest(given.hex(), digest)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def password_problem(password: str | None, username: str = "", extra_weak=frozenset()) -> str | None:
|
|
60
|
+
"""Verstoß gegen die Pflichtregeln (K-4) als Satz für die Anzeige, sonst `None`."""
|
|
61
|
+
if not isinstance(password, str) or not PASSWORD_MIN <= len(password) <= PASSWORD_MAX:
|
|
62
|
+
return _("Das Passwort muss %(min)d bis %(max)d Zeichen lang sein.") % {"min": PASSWORD_MIN,
|
|
63
|
+
"max": PASSWORD_MAX}
|
|
64
|
+
try:
|
|
65
|
+
password.encode("utf-8")
|
|
66
|
+
except UnicodeEncodeError:
|
|
67
|
+
# einzelne UTF-16-Surrogate (z. B. "\ud800" aus JSON) – nicht hashbar, nicht speicherbar
|
|
68
|
+
return _("Das Passwort enthält ungültige Zeichen.")
|
|
69
|
+
pw = password.strip().lower()
|
|
70
|
+
if username and pw == username.strip().lower():
|
|
71
|
+
return _("Das Passwort darf nicht gleich dem Benutzernamen sein.")
|
|
72
|
+
if pw in PLACEHOLDERS or pw in {w.strip().lower() for w in extra_weak}:
|
|
73
|
+
return _("Dieses Passwort steht auf der Liste bekannter Platzhalter – bitte ein anderes wählen.")
|
|
74
|
+
return None
|
pillarz/_auth/setup.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Erstes Admin-Konto anlegen – atomar nach ADR 0015 §5. Paket A nutzt es für
|
|
2
|
+
`python -m pillarz create-admin` (Konsole); Paket D baut darauf die Einrichtungsseite `/setup` und die
|
|
3
|
+
Anlage aus `ADMIN_USERNAME`/`ADMIN_PASSWORD`.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
|
|
9
|
+
from ..db import IntegrityError, connect, now
|
|
10
|
+
from ..i18n import _, lazy
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("pillarz.auth")
|
|
13
|
+
|
|
14
|
+
# labelz app/security.py
|
|
15
|
+
USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,39}$")
|
|
16
|
+
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
17
|
+
NAME_MAX = 100
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SetupClosed(RuntimeError):
|
|
21
|
+
"""Es gibt schon ein Konto bzw. die Einrichtung ist abgeschlossen."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def setup_open(conn) -> bool:
|
|
25
|
+
"""Offen genau dann, wenn `users` leer ist und `pz_meta.setup_done` fehlt (ADR 0015 §1, ohne die
|
|
26
|
+
Konfigurationsbedingungen – die prüft der Aufrufer)."""
|
|
27
|
+
if conn.scalar("SELECT COUNT(*) FROM users"):
|
|
28
|
+
return False
|
|
29
|
+
return not conn.scalar("SELECT COUNT(*) FROM pz_meta WHERE key = 'setup_done'")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_first_admin(username: str, name: str, password_hash: str, *, email: str | None = None,
|
|
33
|
+
source: str = "cli") -> int:
|
|
34
|
+
"""In **einer** Transaktion: `setup_done` eintragen (der Primärschlüssel serialisiert gleichzeitige
|
|
35
|
+
Versuche auf SQLite und PostgreSQL), prüfen, dass `users` leer ist, Admin anlegen. Wirft
|
|
36
|
+
`SetupClosed`, wenn schon jemand schneller war oder Konten existieren."""
|
|
37
|
+
email = (email or "").strip().lower() or None
|
|
38
|
+
try:
|
|
39
|
+
with connect() as conn:
|
|
40
|
+
conn.execute("INSERT INTO pz_meta (key, value) VALUES ('setup_done', ?)", (now(),))
|
|
41
|
+
if conn.scalar("SELECT COUNT(*) FROM users"):
|
|
42
|
+
raise SetupClosed(_("Es gibt schon Konten – die Einrichtung ist abgeschlossen."))
|
|
43
|
+
at = now()
|
|
44
|
+
user_id = conn.insert("users", username=username, name=name, email=email,
|
|
45
|
+
email_confirmed=1 if email else 0, password_hash=password_hash,
|
|
46
|
+
role="admin", created_at=at, updated_at=at)
|
|
47
|
+
# TODO(Paket E): Event user.created mit source=<source> (Veto → Rollback)
|
|
48
|
+
except IntegrityError as exc:
|
|
49
|
+
raise SetupClosed(_("Die Einrichtung ist bereits abgeschlossen.")) from exc
|
|
50
|
+
except sqlite3.OperationalError as exc:
|
|
51
|
+
# SQLite, mehrere Prozesse: ein anderer hält die Schreibsperre länger als busy_timeout – dann
|
|
52
|
+
# richtet gerade jemand anderes ein (S-N7). Andere OperationalErrors bleiben Fehler.
|
|
53
|
+
if "locked" not in str(exc).lower() and "busy" not in str(exc).lower():
|
|
54
|
+
raise
|
|
55
|
+
raise SetupClosed(_("Die Einrichtung läuft gerade an anderer Stelle – bitte gleich neu laden.")) from exc
|
|
56
|
+
log.info(lazy("Erstes Admin-Konto %(username)s angelegt (%(source)s)."), {"username": username, "source": source})
|
|
57
|
+
return user_id
|
pillarz/_db/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Interna der Datenbankschicht (privat): Verbindung/Pool, Uhr, Migrations-Runner, pillarz-Migrationen."""
|