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.
Files changed (62) hide show
  1. pillarz/__init__.py +309 -0
  2. pillarz/__main__.py +176 -0
  3. pillarz/_auth/__init__.py +2 -0
  4. pillarz/_auth/passwords.py +74 -0
  5. pillarz/_auth/setup.py +57 -0
  6. pillarz/_db/__init__.py +1 -0
  7. pillarz/_db/clock.py +67 -0
  8. pillarz/_db/connection.py +241 -0
  9. pillarz/_db/m0001_initial.py +194 -0
  10. pillarz/_db/migrations.py +272 -0
  11. pillarz/_i18n/__init__.py +11 -0
  12. pillarz/_i18n/babel-js.cfg +4 -0
  13. pillarz/_i18n/babel.cfg +8 -0
  14. pillarz/_i18n/catalogs.py +216 -0
  15. pillarz/_i18n/jinja.py +41 -0
  16. pillarz/_i18n/js.py +43 -0
  17. pillarz/_i18n/request.py +103 -0
  18. pillarz/_i18n/tools.py +200 -0
  19. pillarz/_protocol.py +315 -0
  20. pillarz/_runtime.py +86 -0
  21. pillarz/_themes.py +154 -0
  22. pillarz/_web/__init__.py +1 -0
  23. pillarz/_web/app.py +69 -0
  24. pillarz/_web/context.py +14 -0
  25. pillarz/_web/csp.py +148 -0
  26. pillarz/_web/errors.py +132 -0
  27. pillarz/_web/gate.py +134 -0
  28. pillarz/_web/guard.py +241 -0
  29. pillarz/_web/headers.py +62 -0
  30. pillarz/_web/net.py +48 -0
  31. pillarz/_web/pages.py +30 -0
  32. pillarz/_web/render.py +271 -0
  33. pillarz/_web/static.py +40 -0
  34. pillarz/config.py +336 -0
  35. pillarz/db.py +337 -0
  36. pillarz/i18n.py +126 -0
  37. pillarz/locale/en/LC_MESSAGES/pillarz-js.po +86 -0
  38. pillarz/locale/en/LC_MESSAGES/pillarz.po +622 -0
  39. pillarz/locale/pillarz-js.pot +83 -0
  40. pillarz/locale/pillarz.pot +588 -0
  41. pillarz/static/pillarz/css/base.css +531 -0
  42. pillarz/static/pillarz/favicon.svg +1 -0
  43. pillarz/static/pillarz/js/api.js +96 -0
  44. pillarz/static/pillarz/js/i18n.js +158 -0
  45. pillarz/static/pillarz/js/ui.js +433 -0
  46. pillarz/static/pillarz/vendor/lucide/LICENSE +43 -0
  47. pillarz/templates/pillarz/bare.html +25 -0
  48. pillarz/templates/pillarz/base.html +38 -0
  49. pillarz/templates/pillarz/error.html +14 -0
  50. pillarz/templates/pillarz/icons.svg +66 -0
  51. pillarz/templates/pillarz/partials/flash.html +8 -0
  52. pillarz/templates/pillarz/partials/nav.html +30 -0
  53. pillarz/templates/pillarz/plain.html +16 -0
  54. pillarz/testing/__init__.py +149 -0
  55. pillarz/testing/_web.py +112 -0
  56. pillarz/text.py +81 -0
  57. pillarz/web.py +182 -0
  58. pillarz-0.1.0.dev1.dist-info/METADATA +71 -0
  59. pillarz-0.1.0.dev1.dist-info/RECORD +62 -0
  60. pillarz-0.1.0.dev1.dist-info/WHEEL +5 -0
  61. pillarz-0.1.0.dev1.dist-info/licenses/LICENSE +661 -0
  62. 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,2 @@
1
+ """Interna der Anmeldung (privat). Paket A liefert nur, was `create-admin` braucht; Sitzungen,
2
+ Bremse, Ersteinrichtung im Browser usw. kommen mit Paket D."""
@@ -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
@@ -0,0 +1 @@
1
+ """Interna der Datenbankschicht (privat): Verbindung/Pool, Uhr, Migrations-Runner, pillarz-Migrationen."""