From 71c7e9bf5b3a6d23f2e7c30404884c5dfdde4547 Mon Sep 17 00:00:00 2001 From: Thomas Adamek Date: Tue, 6 Oct 2026 13:51:48 +0200 Subject: [PATCH] =?UTF-8?q?Ausbau,=20Erweiterung,=20Graphen,=20Bugfixes,?= =?UTF-8?q?=20Export,=20Stabilit=C3=A4t,=20Lasttest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AUSBAUSTUFEN.md | 145 +++++- BETRIEB.md | 347 +++++++++++++ LASTTEST.md | 224 ++++++++ README.md | 32 +- REPOMAP.md | 87 +++- internal/auth/auth_test.go | 57 +++ internal/auth/doc.go | 8 +- internal/auth/keys.go | 38 +- internal/auth/session.go | 57 ++- internal/export/export.go | 86 ++-- internal/export/zip.go | 101 ++++ internal/hilfe/seiten/begriffe.md | 10 +- internal/hilfe/seiten/plattform-admin.md | 45 +- internal/hilfe/seiten/weboberflaeche.md | 28 +- internal/hilfe/seiten/zugaenge.md | 27 + internal/importer/importer_test.go | 50 ++ internal/mcpserver/gleichzeitig_test.go | 159 ++++++ internal/mcpserver/tools.go | 4 +- internal/search/registry.go | 137 ++++- internal/search/registry_test.go | 208 ++++++++ internal/store/audit.go | 11 + internal/store/control_test.go | 72 +++ .../002_workspace_admins.sql | 27 + internal/store/models.go | 13 +- internal/store/store_test.go | 26 + internal/store/workspace_admins.go | 191 +++++++ internal/web/context.go | 80 ++- internal/web/download_test.go | 186 +++++++ internal/web/handlers_admin.go | 43 +- internal/web/handlers_auth.go | 43 +- internal/web/handlers_download.go | 125 +++++ internal/web/handlers_entries.go | 8 +- internal/web/handlers_import.go | 6 +- internal/web/handlers_papierkorb.go | 2 +- internal/web/handlers_tags.go | 106 ++++ internal/web/handlers_workspace_admins.go | 100 ++++ internal/web/handlers_workspaces.go | 77 ++- internal/web/middleware.go | 129 ++++- internal/web/server.go | 108 +++- internal/web/tags_test.go | 161 ++++++ internal/web/web_test.go | 103 +++- internal/web/workspace_admin_test.go | 478 ++++++++++++++++++ last_test.go | 425 ++++++++++++++++ main.go | 111 ++-- scripts/backup.sh | 4 + static/graph.js | 418 ++++++++++++--- static/style.css | 68 ++- static/tagfilter.js | 67 +++ templates/entries.html | 12 +- templates/graph.html | 6 +- templates/login.html | 2 +- templates/partials.html | 32 +- templates/project-edit.html | 13 + templates/projects.html | 18 +- templates/tags.html | 60 +++ templates/workspace-edit.html | 87 +++- templates/workspaces.html | 11 + 57 files changed, 4950 insertions(+), 329 deletions(-) create mode 100644 BETRIEB.md create mode 100644 LASTTEST.md create mode 100644 internal/export/zip.go create mode 100644 internal/mcpserver/gleichzeitig_test.go create mode 100644 internal/search/registry_test.go create mode 100644 internal/store/migrations_control/002_workspace_admins.sql create mode 100644 internal/store/workspace_admins.go create mode 100644 internal/web/download_test.go create mode 100644 internal/web/handlers_download.go create mode 100644 internal/web/handlers_tags.go create mode 100644 internal/web/handlers_workspace_admins.go create mode 100644 internal/web/tags_test.go create mode 100644 internal/web/workspace_admin_test.go create mode 100644 last_test.go create mode 100644 static/tagfilter.js create mode 100644 templates/tags.html diff --git a/AUSBAUSTUFEN.md b/AUSBAUSTUFEN.md index 1fe247c..4121a40 100644 --- a/AUSBAUSTUFEN.md +++ b/AUSBAUSTUFEN.md @@ -73,6 +73,35 @@ zum Lesen, eine zum Schreiben. Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100 Schreibvorgänge/s — etwa **1 % der Kapazität**. +**Nachmessung über den ganzen Weg (2026-10-06):** `last_test.go` lässt Agenten +über echtes HTTP und MCP arbeiten, mit Key-Prüfung, Protokoll und Suchindex +(Ausführung und alle Ergebnisse in `LASTTEST.md`). 128 Agenten, je 500 Einträge vorab, +30 % Schreiben, 20 umkämpfte Bezeichner je Projekt, 10 Sekunden, Testrechner +mit 10 Kernen, Clients und Server auf derselben Maschine: + +| Aufstellung | `remember` je s | `remember` p50 / p99 | `recall` p50 / p99 | Fehler | +|---|---|---|---|---| +| ein Projekt | 1.427 | 68 ms / 269 ms | 4,5 ms / 23 ms | 0 | +| ein Workspace, 4 Projekte | 1.355 | 73 ms / 286 ms | 4,5 ms / 24 ms | 0 | +| 4 Workspaces, je 1 Projekt | 1.328 | 60 ms / 205 ms | 15 ms / 36 ms | 0 | + +- Kein Schreibvorgang ging verloren, der Suchindex kannte in jedem Lauf den + letzten Stand. +- Projekte im selben Workspace teilen sich die Schreibverbindung: Mehrere + Projekte verhalten sich wie eines. Getrennte Workspaces schreiben etwas + schneller, der Rechner insgesamt bleibt aber die Grenze (die Suche wird + langsamer, weil alles um dieselben Kerne konkurriert). +- Gegenüber 8.656/s oben kostet der volle Weg etwa das Sechsfache. 1.300 + Schreibvorgänge je *Sekunde* stehen einer Vorgabe von 1.000 je *Stunde* und + Projekt gegenüber (Schreibbudget 3.8). Last ist bei echter Nutzung kein Thema. + +Dabei gefunden und behoben: Schrieben zwei Agenten gleichzeitig denselben +Eintrag, konnte der Suchindex eine ältere Fassung behalten als die Datenbank. +`Registry.Refresh` liest seitdem nach jedem Schreiben aus der Datenbank nach, +statt den mitgegebenen Stand zu übernehmen. Außerdem hat seitdem jedes Projekt +seine eigene Sperre in der Registry: Der Aufbau eines großen Index nach einem +Neustart lässt die anderen Projekte desselben Workspace nicht mehr warten. + **Warum aufgeschoben:** Es bräuchte trotzdem eine zentrale Datenbank, denn drei Dinge braucht man, *bevor* man das Projekt kennt: `ResolveKeyHash` (Key → Projekt), `ListProjects` und `EnsureWorkspace`. Daraus folgt: @@ -736,9 +765,9 @@ oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang. - **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`. - **Offen:** Stufe 2 (Ablaufdatum). -### 4.8 Admin je Workspace +### 4.8 Admin je Workspace — ✅ umgesetzt (2026-09-30) -Heute legt nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40 +Bis dahin legte nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40 Teams wird er damit zum Nadelöhr, und ein gemeinsames Token für alle Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen. @@ -746,12 +775,50 @@ Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen. anlegen, umbenennen und löschen darf, aber weder andere Workspaces sieht noch das Schreibbudget ändert (siehe 3.8, warum das beim Plattform-Admin bleibt). -**Abhängigkeit:** Hängt eng mit 4.3 (Keycloak) zusammen. Mit Personen statt Keys -wäre „Workspace-Admin“ einfach eine Gruppenmitgliedschaft. Ohne 4.3 bräuchte es -eine eigene Key-Art ohne Projektbindung. Beides zusammen entscheiden, nicht -4.8 vorziehen und später umbauen. +**Entscheidung (2026-09-30): ohne Keycloak umsetzen.** Ursprünglich stand hier, +4.8 nur zusammen mit 4.3 zu entscheiden. Keycloak wird aktuell aber nicht +gebraucht, das Nadelöhr beim Plattform-Admin dagegen schon. Kommt 4.3 doch, +fallen nur die Token-Tabelle und der Anmeldezweig weg. Die Rechteprüfungen +(`Session.CanManageProjects`, `CanEditWriteBudget`, `CanImport`, die Routen) +bleiben gleich, nur die Herkunft von `IsWorkspaceAdmin` ändert sich. -**Aufwand:** hoch. +**Umsetzung:** + +- *Eigene Token-Art `wa_…`* in der Kontroll-Datenbank (`workspace_admins`, + `migrations_control/002`), Hash statt Klartext, zurückziehbar, mit letzter + Nutzung. Mehrere je Workspace, jeder mit Namen der Person, damit das + Protokoll sagt, wer etwas getan hat. +- *Nur Weboberfläche:* `APIAuth` akzeptiert nur `wk_…` (`LooksLikeKey`). Ein + `wa_`-Token scheitert dort schon an der Form und liegt außerdem in einer + anderen Tabelle. +- *Eigenes Feld `Session.IsWorkspaceAdmin`* statt `IsPlatformAdmin` mitzunutzen. + Jede Prüfung auf `IsPlatformAdmin` weist ihn damit von selbst ab. Freigegeben + ist nur, was die `Can…`-Methoden ausdrücklich nennen. +- *Darf:* Projekte des eigenen Workspace anlegen, bearbeiten, löschen. Zugänge + aller Projekte dort verwalten. Alle Projekte lesen, dazu Protokoll, Nutzung + und `/admin/audit`. +- *Darf nicht:* Einträge schreiben, importieren, Papierkorb. Außerdem + Schreibbudget (der Server ignoriert das Feld, es wird also nicht nur im + Template ausgeblendet), Export, Workspaces, andere Workspace-Admins anlegen. +- *Anlegen und Zurückziehen* nur durch den Plattform-Admin, auf der + Bearbeitungsseite des Workspace. Beides und jede Anmeldung stehen im + Protokoll des Workspace. +- *Ungenutzte Workspace-Admins* markiert wie bei den Keys (4.7, dieselbe Regel + über `store.ungenutzt`). Die Workspace-Übersicht zeigt je Workspace die Zahl + der aktiven und der ungenutzten Workspace-Admins. + +**Bewusst hingenommen:** Wer Zugänge verwalten darf, kann sich einen +`admin`-Key für jedes Projekt seines Workspace anlegen und damit auch +schreiben. Das Verbot, Einträge zu schreiben, ist also keine harte Grenze. Es +sorgt dafür, dass Änderungen über Keys laufen und im Protokoll eindeutig +zuzuordnen sind. Die Rolle gehört deshalb nur an Personen, denen die Abteilung +ohnehin alle ihre Projekte anvertraut (so steht es auch in der Hilfe). + +**Tests:** `internal/web/workspace_admin_test.go` (darf / darf nicht, +Workspace-Trennung, MCP abgewiesen, Zurückziehen beendet die Sitzung), +`TestWorkspaceAdmins`, `TestWorkspaceAdminsVanishWithWorkspace`, +`TestWorkspaceAdminUnused`, `TestWorkspaceListCountsActiveAdmins`, +`TestWorkspaceAdminTokenIsNoKey`. ### 4.9 Zugriff über ein Projekt hinaus — aufgeschoben @@ -842,6 +909,65 @@ messen. Innerhalb eines Workspace erledigt ein solcher `read`-Key nebenbei auch das öffentliche Projekt. Übrig bliebe nur „öffentlich über Workspaces hinweg“ (Variante A oben). +#### Idee: Mehrere Keys in einem MCP-Block (2026-10-06) + +Eine Alternative zu Workspace-Keys, nur als Idee festgehalten, nicht +entschieden. Der Client trägt *einen* Server ein und übergibt alle seine Keys: + +```json +"munin": { + "url": "https://munin.firma.de/mcp", + "headers": { "X-Munin-Keys": "wk_aaa,wk_bbb,wk_ccc" } +} +``` + +Der Server löst jeden Key einzeln auf dem heutigen Weg auf (Key → Workspace → +Projekt + Rolle). Ist einer ungültig, scheitert die ganze Anfrage +(fail-closed). Die Werkzeuge bekommen einen Parameter `project`, `recall` +sucht ohne Angabe in allen freigeschalteten Projekten. Ein einzelner Key in +`Authorization: Bearer` verhält sich wie heute. + +**Vorteile gegenüber Workspace-Keys:** + +- Kein Umbau der Datenbank: `api_keys.project_id` bleibt `NOT NULL`, kein + neuer Key-Typ, keine neue Verwaltung. +- Rechte bleiben je Key: ein `read`-Key bleibt `read`. Sperren, Protokoll + (4.1), Nutzungsübersicht (4.6) und ungenutzte Zugänge (4.7) funktionieren + unverändert. +- Kein größerer Schaden bei einem verlorenen Key: der Client hält dieselben + Keys wie heute, nur in einem statt in N Blöcken. +- Schreibbudget (3.8) bleibt je Projekt. +- Löst das öffentliche Projekt mit: ein `read`-Key aus einem fremden + Workspace ist in der Liste kein Sonderfall, jeder Key wird für sich + geroutet. +- 6 Werkzeuge im Kontext des Agenten statt 6 × N. + +**Risiken für die Richtigkeit der Antworten:** + +| Risiko | Was passiert | Gegenmittel | +|---|---|---| +| Wissen vermischt | „Wir deployen mit Helm“ aus A wird als allgemeingültig wiedergegeben, obwohl B Ansible nutzt | Jeder Treffer nennt sein Projekt deutlich, Instructions: Aussagen gelten nur für ihr Projekt, bei Widerspruch nachfragen | +| Gleicher Slug in mehreren Projekten | `get deployment` liest das falsche Projekt; schlimmer: `remember` **überschreibt** einen bestehenden Eintrag in B mit Inhalt aus A | `project` ist Pflicht für `get`/`remember`/`forget`/`related`, sobald mehr als ein Key übergeben wird. Fehlt es, Fehlermeldung statt Raten | +| Rangfolge unterschlägt Projekte | BM25-Werte getrennter Indizes sind nicht vergleichbar, ein großes Projekt füllt die Top 10, der Agent schließt „nichts bekannt“ | Treffer je Projekt gruppiert zurückgeben (beste N je Projekt) statt einer gemischten Liste | +| Dubletten | Wissen aus A wird in B neu angelegt und veraltet dort still | Nur über Instructions zu mindern | + +Nur der gleiche Slug verändert gespeicherte Daten. Die Pflichtangabe von +`project` schließt das technisch aus. Die anderen drei betreffen die Qualität +der Antworten und lassen sich nur verringern. + +**Aufwand (Schätzung, nicht erprobt):** + +| | Umfang | Aufwand | +|---|---|---| +| **Stufe 1** | Nur lesend: `recall`/`get`/`list`/`related` über mehrere Keys, schreiben weiter über die Projekt-Server | 3–4 Tage | +| **Stufe 2** | Zusätzlich `remember`/`forget` mit Pflichtangabe `project` | +1–2 Tage | + +**Wenn es so weit ist:** mit Stufe 1 beginnen. Dann ist das Überschreiben +ausgeschlossen, und an echten Antworten lässt sich sehen, wie stark sich +Wissen vermischt, bevor Schreiben dazukommt. Speicher und Kaltstart betrifft +nur die Indizes der übergebenen Keys, nicht den ganzen Workspace. Die +Hauptarbeit bei den Tests: nachweisen, dass kein Key mehr sieht als heute. + --- ## Teil 5 — Oberfläche @@ -1048,8 +1174,9 @@ Als Nächstes: `remember`-Aufrufe legen neu an, wie viele aktualisieren? 8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) — beide ändern den MCP-Vertrag. -9. **Admin je Workspace** (4.8) zusammen mit **Keycloak** (4.3). Wird dringend, - sobald das Anlegen von Projekten spürbar auf den Plattform-Admin wartet. +9. ~~**Admin je Workspace** (4.8)~~ — erledigt 2026-09-30, ohne Keycloak + (siehe die Entscheidung dort). **Keycloak** (4.3) bleibt offen, bis es + gebraucht wird. Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es diff --git a/BETRIEB.md b/BETRIEB.md new file mode 100644 index 0000000..007e671 --- /dev/null +++ b/BETRIEB.md @@ -0,0 +1,347 @@ +# Betriebshandbuch + +Was tun, wenn etwas schiefgeht? Diese Seite ist für den Ernstfall geschrieben, +auch für jemanden, der Munin nicht selbst gebaut hat. Jeder Abschnitt beginnt +mit dem, was man sieht, und endet mit dem, was man tut. + +Die Befehle gehen vom Docker-Aufbau aus dem README aus: Container `munin`, +Volume unter `/data`. Ohne Docker gilt dasselbe mit dem eigenen `DATA_DIR` +statt `/data`, und die Ausgabe des Prozesses statt `docker logs`. + +--- + +## Auf einen Blick + +| Was | Wo | +|---|---| +| Kontroll-Datenbank | `DATA_DIR/control.db`: Workspaces, welcher Key zu welchem Workspace gehört, Workspace-Admins | +| Ein Workspace | `DATA_DIR/workspaces/.db`: Projekte, Einträge, Keys, Protokoll dieses Workspace | +| Zugehörige Hilfsdateien | `…db-wal` und `…db-shm` neben jeder Datenbank. Sie gehören im laufenden Betrieb dazu, **nie einzeln löschen, solange der Server läuft** | +| Logs | Standardausgabe des Prozesses: `docker logs munin` | +| Sicherungen | `BACKUP_DIR//` von `scripts/backup.sh`, siehe README „Datensicherung“ | +| Markdown-Export | `EXPORT_DIR`, falls gesetzt. Lesbar, aber **keine** vollständige Sicherung (keine Keys, keine Historie) | +| Geheimnisse | `SESSION_SECRET`, `ADMIN_TOKEN` als Umgebungsvariablen, siehe README „Konfiguration“ | +| Lebenszeichen | `GET /healthz` antwortet `ok` | + +Welcher Workspace hinter einer ID steckt: + +```sh +sqlite3 /data/control.db "SELECT id, name FROM workspaces;" +``` + +--- + +## Läuft er? + +```sh +curl -s https:///healthz # erwartet: ok +docker logs --tail 50 munin +``` + +Beim Start schreibt Munin diese Zeilen ins Log. Fehlen sie, ist der Start +gescheitert: + +``` +Munin läuft auf http://localhost:8080 (3 Workspaces) +Workspaces: /data/workspaces (Kontrolle: /data/control.db) +Schreibbudget: 1000 Aufrufe je Projekt und Stunde (Vorgabe) +``` + +`/healthz` prüft nur, ob der Prozess antwortet, nicht die Datenbanken. Ein +`ok` heißt also nicht, dass jeder Workspace lesbar ist. Siehe dazu +[Datenbank beschädigt](#datenbank-beschädigt). + +--- + +## Server startet nicht + +**Man sieht:** Der Container beendet sich sofort, `/healthz` antwortet nicht. + +`docker logs munin` zeigt die Ursache in der letzten Zeile: + +| Meldung | Ursache | Abhilfe | +|---|---|---| +| `SESSION_SECRET ist nicht gesetzt` / `zu kurz` | Umgebungsvariable fehlt oder hat weniger als 32 Zeichen | Aus dem Passwortspeicher setzen. **Nicht** neu erzeugen, wenn es ein altes gibt, sonst sind alle Anmeldungen weg | +| `ADMIN_TOKEN ist nicht gesetzt` / `zu kurz` | dasselbe, mindestens 16 Zeichen | dasselbe | +| `Datenverzeichnis konnte nicht angelegt werden` | Volume fehlt oder gehört nicht dem Benutzer im Container (UID 10001) | `chown -R 10001:10001` auf das Volume, oder richtig einhängen | +| `unable to open database file`, `permission denied` | dasselbe für eine einzelne Datei, oft nach einem Zurückkopieren als root | Besitzer der Datei korrigieren | +| `address already in use` | Ein anderer Prozess belegt den Port | anderen Prozess beenden oder `PORT` ändern | +| `database disk image is malformed` | beschädigte Datenbank | [Datenbank beschädigt](#datenbank-beschädigt) | + +--- + +## Platte voll + +**Man sieht:** Lesen geht noch, Schreiben schlägt fehl. Im Log steht +`database or disk is full`, Agenten bekommen Fehler bei `remember`. + +SQLite bleibt dabei heil. Ein Schreibvorgang, der nicht mehr passt, wird +vollständig zurückgerollt, halbe Einträge gibt es nicht. + +1. Nachsehen, was wächst: `du -sh /data/* /data/workspaces/* /* ` +2. Platz schaffen, in dieser Reihenfolge: + - alte Sicherungen löschen und `RETENTION_DAYS` im Cronjob setzen, falls es fehlt + - Docker-Logs begrenzen (`--log-opt max-size=50m`) +3. **Nicht** die `-wal`- oder `-shm`-Dateien löschen. Darin stehen Änderungen, + die noch nicht in der Hauptdatei sind. + +Sobald wieder Platz ist, schreibt Munin ohne Neustart weiter. + +--- + +## Ein Key ist in falsche Hände geraten + +Zum Beispiel in ein öffentliches Repository eingecheckt, in einem Chat +gepostet, oder der Laptop ist weg. + +1. Mit Admin-Rechten anmelden, ins Projekt wechseln, **Zugänge** → beim + betroffenen Key **Zurückziehen**. Das wirkt sofort, für MCP-Aufrufe und für + Sitzungen in der Weboberfläche. Jede Anfrage prüft den Key neu in der + Datenbank. +2. Unter **Protokoll** in der Spalte *Wer* nach dem Namen des Keys suchen (ein + Filter fehlt bisher, die Suche des Browsers hilft): Was wurde damit getan, + von welchen Adressen? Unbekannte Adressen deuten auf Missbrauch hin. +3. Hat der Key geschrieben, was er nicht sollte: Einträge über ihre + **Historie** auf eine frühere Fassung zurücksetzen, Gelöschtes aus dem + **Papierkorb** holen. +4. Dem Team einen neuen Key ausstellen. + +Wer den Key nicht kennt, findet ihn über den Workspace: Der Plattform-Admin +sieht alle Workspaces, ein Workspace-Admin alle Projekte seines Workspace. + +--- + +## Ein Workspace-Admin-Token (`wa_…`) ist in falsche Hände geraten + +Ein solches Token öffnet den ganzen Workspace. + +1. Als Plattform-Admin **Workspaces** → **Bearbeiten** → Abschnitt + *Workspace-Admins* → **Zurückziehen**. Das wirkt sofort, auch für eine + laufende Sitzung. +2. **Protokoll (alle)** des Workspace prüfen: angelegte oder gelöschte + Projekte, neue Keys. Keys, die in der fraglichen Zeit angelegt wurden, + zurückziehen. +3. Neues Token für die Person ausstellen. + +--- + +## `ADMIN_TOKEN` ist in falsche Hände geraten + +Mit dem `ADMIN_TOKEN` kann sich jeder als Plattform-Admin anmelden und kommt +an jeden Workspace. + +```sh +openssl rand -hex 24 # neues ADMIN_TOKEN +``` + +1. Neuen Wert im Passwortspeicher ablegen und in der Konfiguration des + Containers setzen. +2. Container neu starten. **Alle Sitzungen des Plattform-Admins enden damit + sofort**, auch die eines Angreifers, der sich schon angemeldet hat. Jede + Admin-Sitzung trägt einen Fingerabdruck des Tokens, mit dem sie entstanden + ist, und der Server vergleicht ihn bei jeder Anfrage (seit 2026-10-06, + siehe `auth.PlatformSubject`). Teams und Workspace-Admins bleiben + angemeldet, MCP-Agenten merken nichts. +3. [Prüfen, was in der fraglichen Zeit geschah](#was-ist-in-der-fraglichen-zeit-geschehen). + +## `SESSION_SECRET` ist in falsche Hände geraten + +Mit dem `SESSION_SECRET` kann jeder gültige Anmelde-Cookies selbst ausstellen, +für jeden Zugang. Da der Fingerabdruck des `ADMIN_TOKEN` ebenfalls aus dem +`SESSION_SECRET` berechnet wird, schließt das den Plattform-Admin ein, ohne +dass das Token je bekannt war. Das ist der schwerste Fall. + +```sh +openssl rand -hex 32 # neues SESSION_SECRET +openssl rand -hex 24 # neues ADMIN_TOKEN, zur Sicherheit gleich mit +``` + +1. Beide Werte im Passwortspeicher ersetzen und in der Konfiguration des + Containers setzen. +2. Container neu starten. Alle sind danach aus der Weboberfläche abgemeldet und + melden sich mit ihrem Key neu an. **MCP-Agenten merken nichts davon**, sie + nutzen ihre Keys direkt, nicht die Cookies. +3. [Prüfen, was in der fraglichen Zeit geschah](#was-ist-in-der-fraglichen-zeit-geschehen). + +### Was ist in der fraglichen Zeit geschehen? + +- Anmeldungen des Plattform-Admins stehen **nur im Server-Log**, nicht im + Protokoll: `docker logs munin 2>&1 | grep "Anmeldung Plattform-Admin"`. + Unbekannte Adressen sind ein Warnsignal. Ein selbst ausgestelltes Cookie + (geleaktes `SESSION_SECRET`) hinterlässt diese Zeile allerdings nicht. +- In jedem Workspace das **Protokoll (alle)**: neue Projekte, Keys und + Workspace-Admins. Die Liste der Workspaces auf unbekannte Einträge prüfen. + Was nicht von dir stammt, zurückziehen oder löschen. + +--- + +## Ein Agent dreht durch + +**Man sieht:** Ein Projekt füllt sich mit Unsinn, oder Agenten melden, dass das +Schreibbudget erschöpft ist. + +Das Schreibbudget (Vorgabe 1.000 Aufrufe je Projekt und Stunde) begrenzt den +Schaden bereits. Ein Agent, der in einer Schleife hängt, wird nach spätestens +einer Stunde gebremst. + +1. **Nutzung** im Projekt zeigt, welcher Key wie viel geschrieben hat. +2. Den Key des Agenten **zurückziehen** (siehe oben) oder das Team bitten, den + Agenten zu stoppen. +3. Aufräumen über **Historie** und **Papierkorb**. +4. Braucht ein Projekt dauerhaft mehr: Der Plattform-Admin setzt unter + *Projekte → Bearbeiten* eine eigene Obergrenze. + +--- + +## Einträge versehentlich gelöscht oder überschrieben + +Fast immer ohne Sicherung lösbar: + +- **Überschrieben:** Eintrag öffnen → **Historie** → frühere Fassung + wiederherstellen. Jede Fassung bleibt erhalten. +- **Gelöscht:** **Papierkorb** im Projekt, 30 Tage lang. +- **Endgültig gelöscht oder älter als 30 Tage im Papierkorb:** nur noch aus einer + Sicherung. Für einzelne Einträge reicht oft der Markdown-Export: Datei + heraussuchen, als ZIP packen und über **Import** im Projekt wieder einlesen. +- **Ganzes Projekt gelöscht:** nur aus einer Sicherung, siehe unten. + +--- + +## Aus einer Sicherung wiederherstellen + +Der Ablauf ist am 2026-10-06 einmal vollständig durchgespielt worden (Sicherung +im laufenden Betrieb, Zurückkopieren, Start). + +### Vorher bedenken + +Eine Sicherung setzt **alles** in der zurückgespielten Datei auf ihren Stand +zurück, nicht nur das, was man retten will: + +- Einträge und Fassungen nach der Sicherung sind weg. +- Keys, die nach der Sicherung **angelegt** wurden, gelten nicht mehr. +- Keys, die nach der Sicherung **zurückgezogen** wurden, **gelten wieder.** + Nach dem Zurückspielen deshalb unter *Zugänge* nachsehen und erneut + zurückziehen. Das ist der gefährlichste Nebeneffekt. + +Wer nur ein paar Einträge braucht, spielt die Sicherung besser in ein +**getrenntes Verzeichnis** zurück, startet dort einen zweiten Server auf einem +anderen Port, exportiert die Einträge und importiert sie im echten Server. +Dann bleibt alles andere unberührt. + +### Einen Workspace zurückholen + +Die Kontroll-Datenbank bleibt dabei, wie sie ist. Die anderen Workspaces merken +nichts davon. + +```sh +# 1. Welche Datei? (ID ↔ Name) +sqlite3 /data/control.db "SELECT id, name FROM workspaces;" + +# 2. Sicherung prüfen, bevor man sich auf sie verlässt +sqlite3 //workspaces/.db "PRAGMA integrity_check;" # erwartet: ok + +# 3. Server stoppen +docker stop munin + +# 4. Die Hilfsdateien der alten Fassung entfernen. Nach einem Absturz liegen +# sie noch da, und SQLite würde sonst ihren Inhalt in die zurückgespielte +# Datei übernehmen. +rm -f /data/workspaces/.db-wal /data/workspaces/.db-shm + +# 5. Die kaputte oder falsche Datei zur Seite legen, nicht löschen +mv /data/workspaces/.db /data/workspaces/.db.vorher + +# 6. Zurückkopieren, Besitzer richtig setzen +cp //workspaces/.db /data/workspaces/ +chown 10001:10001 /data/workspaces/.db + +# 7. Starten und prüfen +docker start munin +curl -s https:///healthz +``` + +Danach in der Weboberfläche den Workspace öffnen und **zurückgezogene Keys +erneut zurückziehen** (siehe oben). Läuft alles, kann `.db.vorher` weg. + +### Alles zurückholen + +Wie oben, aber mit `control.db` und **allen** Dateien unter `workspaces/`, und +mit allen `-wal`/`-shm`-Dateien. Zusätzlich zu den Keys gelten dann auch +Workspace-Admins wieder, die nach der Sicherung zurückgezogen wurden. + +### Ein gelöschter Workspace + +Beim Löschen verschwinden die Datei **und** sein Eintrag in der +Kontroll-Datenbank. Nur die Datei zurückzukopieren reicht deshalb nicht, der +Server kennt den Workspace nicht mehr. Es braucht auch die `control.db` aus +derselben Sicherung, und die setzt alles Zentrale zurück: andere Workspaces, +Workspace-Admins, die Zuordnung neuer Keys. Das ist eine bewusste Entscheidung. +Meist ist der Weg über einen getrennten zweiten Server mit Export und Import +der bessere. + +--- + +## Datenbank beschädigt + +**Man sieht:** `database disk image is malformed` im Log, oder ein Workspace +lässt sich nicht öffnen, während andere gehen. Workspace-Dateien werden erst +beim ersten Zugriff geöffnet, deshalb kann der Fehler auch erst Stunden nach +dem Start auftauchen. + +```sh +docker stop munin +sqlite3 /data/workspaces/.db "PRAGMA integrity_check;" +``` + +Alles außer `ok` heißt: [aus der Sicherung zurückholen](#einen-workspace-zurückholen). +Nicht versuchen, die Datei zu reparieren. Mit der letzten guten Sicherung ist +man schneller und sicherer am Ziel. + +Häufigste Ursache ist eine Datei, die im laufenden Betrieb mit `cp` statt mit +`scripts/backup.sh` kopiert und dann zurückgespielt wurde. + +--- + +## Eine neue Fassung einspielen + +Das Schema der Datenbanken wird beim Start automatisch angepasst +(`internal/store/migrations*`). Der Weg zurück auf eine ältere Fassung ist +danach nicht garantiert. Deshalb: + +1. `scripts/backup.sh` von Hand laufen lassen. +2. Neues Abbild bauen, Container ersetzen. +3. Log auf die Startzeilen prüfen, `/healthz`, einmal in der Weboberfläche + suchen und ein MCP-`recall` absetzen. +4. Geht etwas schief: altes Abbild starten **und** die Sicherung aus Schritt 1 + zurückspielen. + +Ein Neustart beendet laufende Anfragen sauber (bis zu 10 Sekunden). Agenten +verbinden sich danach von selbst neu. + +--- + +## Regelmäßig + +| Wann | Was | +|---|---| +| wöchentlich | Läuft die Sicherung? Ein Blick ins Log des Cronjobs und auf den neuesten Ordner in `BACKUP_DIR`. | +| monatlich | Marke *ungenutzt* bei Keys und Workspace-Admins (Übersicht unter *Workspaces*). Vergessene Zugänge zurückziehen. | +| vierteljährlich | **Probe der Sicherung:** die neueste Sicherung in ein leeres Verzeichnis kopieren, `PRAGMA integrity_check` für jede Datei, einen zweiten Server mit `DATA_DIR` darauf und anderem `PORT` starten, einmal anmelden und suchen. Eine Sicherung, die nie zurückgespielt wurde, ist nur eine Hoffnung. | +| vor jedem Update | Sicherung von Hand, siehe oben | + +Die Probe der Sicherung ohne Docker, auf einem beliebigen Rechner mit Go und +sqlite3: + +```sh +probe=$(mktemp -d) +cp -r //. "$probe/" +for f in "$probe"/control.db "$probe"/workspaces/*.db; do + echo "$f: $(sqlite3 "$f" 'PRAGMA integrity_check;')" +done +DATA_DIR="$probe" PORT=18080 SESSION_SECRET=$(openssl rand -hex 32) \ + ADMIN_TOKEN=probe-probe-probe-1 go run . +# im Browser: http://localhost:18080, Anmeldung mit probe-probe-probe-1 +``` + +Die Probe darf mit eigenen Geheimnissen laufen. Sie braucht weder die echten +noch erreicht sie den echten Server. diff --git a/LASTTEST.md b/LASTTEST.md new file mode 100644 index 0000000..1c90487 --- /dev/null +++ b/LASTTEST.md @@ -0,0 +1,224 @@ +# Lasttest + +Wie viel hält Munin aus, wenn viele Agenten gleichzeitig lesen und schreiben, +in einem Projekt, in mehreren Projekten eines Workspace oder über mehrere +Workspaces verteilt? Der Lasttest in `last_test.go` beantwortet das mit echten +Aufrufen über HTTP und MCP, also über denselben Weg wie im Betrieb: +Key-Prüfung, Protokoll, Datenbank und Suchindex. + +--- + +## Ausführen + +Der Test läuft **nicht** bei `go test ./...` mit. Er braucht Zeit und lastet +den Rechner voll aus. Das Build-Tag `last` schaltet ihn erst ein, wenn man +ausdrücklich danach fragt: + +```sh +go test -tags last -run TestLast -v . +``` + +Mit eigenen Werten: + +```sh +go test -tags last -run TestLast -v . -last.agenten=128 -last.dauer=30s +``` + +Mit dem Race-Detektor, der Datenwettläufe zwischen Goroutinen meldet. Er macht +alles etwa zehnmal langsamer, deshalb mit kleineren Werten: + +```sh +go test -race -tags last -run TestLast . -last.dauer=3s -last.agenten=16 -last.eintraege=100 +``` + +Der Test legt alles in einem temporären Verzeichnis an und räumt danach auf. +Echte Daten in `data/` fasst er nicht an. + +### Schalter + +| Schalter | Vorgabe | Bedeutung | +|---|---|---| +| `-last.agenten` | 32 | Gleichzeitige Agenten je Aufstellung. Jeder hat einen eigenen Key und eine eigene MCP-Sitzung. | +| `-last.dauer` | 10s | Laufzeit je Aufstellung. Es gibt drei Aufstellungen, die Gesamtdauer ist also etwa das Dreifache. | +| `-last.projekte` | 4 | Zahl der Projekte bzw. Workspaces in den Aufstellungen mit mehreren | +| `-last.eintraege` | 500 | Einträge, die jedes Projekt vorab bekommt, damit die Suche einen realistischen Index hat | +| `-last.slugs` | 20 | Bezeichner, auf die die Agenten je Projekt schreiben. Weniger heißt mehr Gedränge auf denselben Einträgen. | +| `-last.schreibanteil` | 30 | Anteil von `remember` in Prozent. Der Rest teilt sich 2 : 1 auf `recall` und `get` auf. | + +--- + +## Was der Test macht + +### Drei Aufstellungen + +Alle drei laufen mit derselben Zahl Agenten, damit die Zahlen vergleichbar +sind: + +| Aufstellung | Was sie zeigt | +|---|---| +| **ein Projekt** | Alle Agenten im selben Projekt. Der Grundwert. | +| **ein Workspace, N Projekte** | Die Projekte liegen in einer Datei und teilen sich die **eine Schreibverbindung** (siehe `openPool` in `internal/store/store.go`). Bremsen sich die Projekte gegenseitig? | +| **N Workspaces, je 1 Projekt** | Jeder Workspace hat seine eigene Datei. Hier kann echt parallel geschrieben werden. | + +### Was ein Agent tut + +Jeder Agent verbindet sich mit seinem Key über `/mcp` und ruft bis zum Ende der +Laufzeit in einer Schleife zufällig auf: + +- `remember` auf einen der umkämpften Bezeichner (`gemeinsam-00` …). Jeder + Schreibvorgang enthält ein **eindeutiges Wort**, an dem sich später erkennen + lässt, welche Fassung wo gelandet ist. +- `recall` mit einem von acht Fachbegriffen, die in den vorab angelegten + Einträgen vorkommen +- `get` auf einen der vorab angelegten Einträge + +Das Schreibbudget (Ausbaustufe 3.8) ist im Test abgeschaltet. Es soll eine +Schleife stoppen und würde sonst die Messung abwürgen. + +### Was am Ende geprüft wird + +Der Test schlägt fehl, wenn eines davon nicht stimmt: + +1. **Kein Aufruf ist fehlgeschlagen.** +2. **Kein Schreibvorgang ging verloren:** Jedes erfolgreiche `remember` erzeugt + eine Fassung. Die Summe der Fassungen aller umkämpften Einträge muss genau + der Zahl der erfolgreichen Aufrufe entsprechen. +3. **Der Suchindex kennt den letzten Stand:** Das eindeutige Wort der letzten + gespeicherten Fassung muss den Eintrag über die Suche finden. + +### Die Ausgabe lesen + +Für jede Aufstellung erscheint eine Tabelle: + +``` +Werkzeug Aufrufe je s p50 p95 p99 max Fehler +verbinden 128 0 18.03ms 33.46ms 35.04ms 35.54ms 0 +recall 21496 2150 4.45ms 8.02ms 23.05ms 40.33ms 0 +get 11381 1138 1.04ms 2.4ms 3.18ms 7.33ms 0 +remember 14269 1427 67.84ms 190.04ms 269.16ms 439.31ms 0 +``` + +- **je s:** erfolgreiche Aufrufe pro Sekunde +- **p50:** Die Hälfte der Aufrufe war schneller, also die typische Dauer. +- **p95 / p99:** Die langsamsten 5 % bzw. 1 % der Aufrufe dauerten mindestens + so lange. Das sind die Werte, die ein Nutzer als „hängt manchmal“ erlebt. +- **max:** der langsamste Aufruf überhaupt +- **verbinden:** der MCP-Verbindungsaufbau, einmal je Agent + +Darunter steht `Bestand geprüft: … Fassungen, Suchindex aktuell`. + +--- + +## Ergebnis vom 2026-10-06 + +**Testrechner:** Mac mit 10 Kernen. Clients und Server liefen auf derselben +Maschine, jeweils 500 Einträge vorab, 30 % Schreiben, 20 umkämpfte Bezeichner +je Projekt, 4 Projekte bzw. Workspaces. + +### 128 Agenten, 10 Sekunden + +| Aufstellung | `remember` je s | `remember` p50 / p95 / p99 | `recall` p50 / p99 | `get` p50 / p99 | Fehler | +|---|---|---|---|---|---| +| ein Projekt | 1.427 | 68 / 190 / 269 ms | 4,5 / 23 ms | 1,0 / 3,2 ms | 0 | +| ein Workspace, 4 Projekte | 1.355 | 73 / 201 / 286 ms | 4,5 / 24 ms | 1,0 / 3,4 ms | 0 | +| 4 Workspaces, je 1 Projekt | 1.328 | 60 / 151 / 205 ms | 15 / 36 ms | 3,8 / 12 ms | 0 | + +### 32 Agenten, 5 Sekunden + +| Aufstellung | `remember` je s | `remember` p50 / p99 | `recall` p50 / p99 | Fehler | +|---|---|---|---|---| +| ein Projekt | 1.374 | 13 / 42 ms | 4,7 / 11 ms | 0 | +| ein Workspace, 4 Projekte | 1.387 | 13 / 46 ms | 4,4 / 10 ms | 0 | +| 4 Workspaces, je 1 Projekt | 1.141 | 8 / 18 ms | 10 / 24 ms | 0 | + +In allen Läufen ging kein Schreibvorgang verloren, und der Suchindex war am +Ende aktuell. Auch der Lauf mit Race-Detektor war ohne Befund. + +### Was die Zahlen sagen + +- **Last ist bei echter Nutzung kein Thema.** Der Server schafft rund 1.300 + Schreibvorgänge pro *Sekunde*. Das Schreibbudget erlaubt 1.000 pro *Stunde* + und Projekt, und echte Agenten liegen bei ein paar Dutzend pro Stunde. +- **Projekte im selben Workspace verhalten sich wie ein Projekt.** Sie teilen + sich die Schreibverbindung, Schreibvorgänge stellen sich also an. Mit mehr + Agenten wachsen deshalb die Wartezeiten beim Schreiben (p50 von 13 ms bei 32 + auf 68 ms bei 128 Agenten), aber nicht die Fehler. +- **Mehrere Workspaces schreiben etwas schneller, suchen aber langsamer.** + Getrennte Dateien heben die Warteschlange beim Schreiben auf. Dafür arbeiten + dann mehr Schreiber gleichzeitig und konkurrieren mit der Suche um dieselben + Kerne. Auf einem Rechner ist die CPU die eigentliche Grenze, nicht SQLite. +- **Der volle Weg kostet etwa das Sechsfache** gegenüber dem reinen Schreiben in + die Datenbank (8.656/s, siehe `AUSBAUSTUFEN.md` 1.2). Dazu kommen HTTP, MCP, + Key-Prüfung, Protokolleintrag und Suchindex. + +### Grenzen der Messung + +- **Clients und Server teilen sich den Rechner.** Die Agenten verbrauchen selbst + CPU. Ein Server allein schafft mehr, die Zahlen sind eher eine Untergrenze. +- **Kaltstart nicht gemessen.** Der Suchindex jedes Projekts ist nach dem + Anlegen der Einträge schon aufgebaut. Der erste Zugriff auf ein großes + Projekt nach einem Neustart (etwa 170 ms je 10.000 Einträge, siehe + `AUSBAUSTUFEN.md` 1.1) ist nicht enthalten. Während dieses Aufbaus wartet nur + dieses eine Projekt. Bis 2026-10-06 warteten auch alle anderen Projekte + desselben Workspace, das ist behoben (siehe unten). +- **Nur MCP.** Die Weboberfläche ist nicht dabei. +- **Kein Rückkanal:** Der zusätzliche SSE-Kanal, den manche MCP-Clients offen + halten, ist abgeschaltet. Er kostet je Agent eine offene Verbindung, aber + kaum Rechenzeit. +- **Gleichmäßige Dauerlast.** Echte Agenten arbeiten in Schüben mit Pausen + dazwischen. Der Test ist also deutlich härter als die Wirklichkeit. + +--- + +## Dabei gefunden + +**Suchindex hielt eine ältere Fassung (behoben).** Speichern und Index +aktualisieren waren zwei getrennte Schritte, und der Index übernahm den Stand, +den der Aufrufer mitgab. Überholten sich zwei Schreiber auf demselben Eintrag +zwischen diesen Schritten, stand Fassung 3 in der Datenbank, aber Fassung 2 im +Index. `recall` fand den Eintrag dann bis zum Neustart über veraltete Wörter. +Genauso konnte ein gerade gelöschter Eintrag durch eine verspätete +Aktualisierung wieder in der Suche auftauchen. + +Unter Last ließ sich das nicht zuverlässig auslösen, das Zeitfenster liegt bei +Mikrosekunden. Mit gezielt vertauschter Reihenfolge war es sofort +reproduzierbar. + +**Behebung:** `Registry.Refresh` in `internal/search/registry.go` liest nach +jedem Schreiben den aktuellen Stand aus der Datenbank nach. Der letzte Aufruf +liest damit immer den neuesten Stand, egal in welcher Reihenfolge die Aufrufe +ankommen. Abgesichert durch `internal/search/registry_test.go` und +`internal/mcpserver/gleichzeitig_test.go`. + +**Aufbau des Suchindex blockierte den ganzen Workspace (behoben).** Die +Registry hatte nur eine Sperre für alle Projekte eines Workspace und hielt sie +auch, während der Index eines Projekts aus der Datenbank aufgebaut wurde. Nach +einem Neustart musste so jedes Projekt warten, bis das größte fertig +eingelesen war. Seit 2026-10-06 hat jedes Projekt seine eigene Sperre. +Abgesichert durch `TestLangsamerAufbauBremstAndereProjekteNicht` und +`TestAenderungWaehrendDesAufbausGehtNichtVerloren`: Ein Schreibvorgang +während des Aufbaus wartet, bis der Aufbau fertig ist, und trägt seinen Stand +danach nach. + +**Stolperstein beim Messen:** Mit den Vorgaben von Go hält ein HTTP-Client nur +zwei Verbindungen je Ziel offen. Bei 128 Agenten öffnete der Test dadurch +laufend neue Verbindungen, bis dem Mac die lokalen Ports ausgingen +(`connect: can't assign requested address`). Das ist ein Fehler der Messung, +nicht des Servers, denn echte Agenten sitzen auf verschiedenen Rechnern. Der +Test nutzt deshalb einen gemeinsamen Transport mit einer offenen Verbindung je +Agent. Taucht die Meldung wieder auf, liegt die Ursache dort. + +--- + +## Wann erneut messen + +- Nach Änderungen am Schreibweg: `PutEntry`, Protokoll, Suchindex, + Verbindungspools in `internal/store/store.go` +- Vor einem Umzug auf andere Hardware oder in einen Container mit begrenzter + CPU. Dann am besten Server und Test auf getrennten Rechnern, oder den Test + mit weniger Agenten. +- Wenn `AUSBAUSTUFEN.md` 4.9 (mehrere Keys in einem MCP-Block) gebaut wird: + Ein `recall` über mehrere Projekte belastet die Suche anders als heute. + +Neue Ergebnisse bitte mit Datum unter **Ergebnis** ergänzen, die alten stehen +lassen. So bleibt sichtbar, wie sich Änderungen ausgewirkt haben. diff --git a/README.md b/README.md index 99b7202..8428afa 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh | `/admin/workspaces` | Workspaces auflisten, anlegen und wählen (nur Plattform-Admin) | | `/admin/workspaces/{id}/edit` | Workspace umbenennen oder löschen | | `/admin/projects` | Projekte des gewählten Workspace anlegen und auflisten, mit Schreibvorgängen der letzten 30 Tage je Projekt; Markdown-Export anstoßen | +| `/admin/export.zip` | Alle Projekte des gewählten Workspace als ZIP herunterladen (Plattform- und Workspace-Admin) | | `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen | | `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace | | `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, Schlagworte für viele Einträge auf einmal ändern | @@ -271,6 +272,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh | `/p/{projekt}/e/{bezeichner}/versions/{n}` | Fassung `n` mit ihrer Nachfolgerin vergleichen | | `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph | | `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) | +| `/p/{projekt}/export.zip` | Projekt als ZIP herunterladen, passt direkt in den Import (Rolle `admin`) | | `/p/{projekt}/usage` | Nutzung: Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Bestand, Verweise ins Leere, aktive Zugänge | | `/p/{projekt}/audit` | Protokoll des Projekts | | `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) | @@ -347,6 +349,7 @@ besser als unsicherer Betrieb. | Signierte Sitzungs-Cookies (HMAC-SHA256) | `internal/auth/session.go` | | API-Keys nur als SHA-256-Prüfsumme gespeichert | `internal/auth/keys.go` | | Zurückgezogener Key beendet laufende Sitzungen sofort | `internal/web/middleware.go` | +| Neues `ADMIN_TOKEN` beendet alle Sitzungen des Plattform-Admins sofort | `internal/auth/session.go` (`PlatformSubject`) | | CSRF-Token in allen Formularen | `internal/auth/csrf.go` | | `SameSite=Lax`, `HttpOnly`, `Secure` | `internal/auth/cookies.go` | | Rate-Limiting je Adresse, nur auf Fehlversuche | `internal/auth/ratelimit.go` | @@ -393,12 +396,14 @@ trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jed - **Keine Benutzerkonten.** Zugänge sind Keys, keine Personen. Ein Key je Person ist der Ersatz — geplant ist später Keycloak. -- **Projekte pflegt nur der Plattform-Admin.** Ein Team kann seinen eigenen - Projektnamen nicht selbst korrigieren. Solange es wenige Projekte gibt, ist - eine Berechtigungsstufe weniger den Umweg wert. +- **Projekte pflegen Plattform-Admin und Workspace-Admins.** Ein Team mit + einem Projekt-Key kann seinen eigenen Projektnamen nicht selbst korrigieren, + dafür braucht es den Admin seines Workspace (AUSBAUSTUFEN.md 4.8). - **Kein TLS im Server selbst.** Für HTTPS gehört ein Reverse-Proxy davor. -- **Ein Admin-Token für alle Workspaces.** Es gibt keinen Admin je Workspace. - Wer den `ADMIN_TOKEN` hat, kommt an jeden Workspace. +- **Ein Plattform-Admin über allem.** Workspace-Admins sind auf ihren + Workspace beschränkt, aber wer den `ADMIN_TOKEN` hat, kommt an jeden + Workspace. Ein neues Token beendet alle Sitzungen damit sofort (siehe + `BETRIEB.md`). - **Das Schreibbudget zählt im Arbeitsspeicher.** Nach einem Neustart steht es wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche Zusage ist es nicht. @@ -407,6 +412,10 @@ trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jed ## Betrieb +Was im Ernstfall zu tun ist (Server startet nicht, Platte voll, Key geleakt, +Wiederherstellen aus der Sicherung), steht im Betriebshandbuch +[`BETRIEB.md`](BETRIEB.md). + ### Docker ```bash @@ -459,7 +468,9 @@ Für Cron, täglich um 3 Uhr mit 14 Tagen Aufbewahrung: Da jede Abteilung ihre eigene Datei hat, lässt sich eine einzelne davon auch gezielt sichern oder wiederherstellen, ohne die übrigen anzufassen: Server stoppen, die gewünschte `.db`-Datei aus einem Sicherungsordner zurückkopieren, -Server wieder starten. +Server wieder starten. Die genauen Schritte und was dabei zu beachten ist +(Hilfsdateien `-wal`/`-shm`, wieder gültige Keys) stehen in +[`BETRIEB.md`](BETRIEB.md#aus-einer-sicherung-wiederherstellen). **Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand aller Workspaces täglich als Markdown-Dateien dorthin: ein Ordner je Workspace, @@ -503,6 +514,15 @@ Scheitert der Export eines Workspace, laufen die übrigen trotzdem durch. `./munin -export` endet dann aber mit einem Fehlercode, damit ein Cronjob die Sicherung nicht für vollständig hält. +**Download als ZIP.** Unabhängig von `EXPORT_DIR` lässt sich derselbe Inhalt +auch direkt im Browser herunterladen: ein Projekt über *Als ZIP herunterladen* +über der Eintragsliste (Rolle `admin`), ein ganzer Workspace über *Alle als ZIP +herunterladen* unter *Projekte*. Das ZIP entsteht bei jedem Klick frisch und +wird nicht auf dem Server abgelegt. Es hat denselben Aufbau wie der Export und +passt deshalb ohne Umpacken in den Import. Jeder Download steht im Protokoll +(`export_download`). Wer außer Admins herunterladen darf, steht an einer +einzigen Stelle: `Session.CanDownloadProject` in `internal/web/context.go`. + Der Export ist eine Sicherung, keine zweite Wahrheit — Änderungen an den Dateien wirken nicht zurück und gehen beim nächsten Lauf verloren. diff --git a/REPOMAP.md b/REPOMAP.md index e82db5d..a39df2c 100644 --- a/REPOMAP.md +++ b/REPOMAP.md @@ -9,12 +9,14 @@ munin/ │ Mindestlänge), öffnet Kontroll-Datenbank und │ Workspace-Verwaltung (legt beim ersten Start den │ Workspace "Intern" an), setzt die -│ Bestandteile zusammen und startet den Server. +│ Bestandteile zusammen (baueHandler, auch vom +│ Lasttest genutzt) und startet den Server. │ Enthält außerdem die Server-Timeouts (gegen │ Slowloris), die Body-Größenlimits und die │ täglichen Aufgaben (Protokoll aufräumen, │ Export), die über fuerJedenWorkspace alle -│ Workspaces erreichen. Tests: main_test.go. +│ Workspaces erreichen. Tests: main_test.go, +│ Lasttest: last_test.go (nur mit -tags last). │ ├── go.mod / go.sum ← Moduldefinition und Prüfsummen der Abhängigkeiten. │ @@ -33,9 +35,14 @@ munin/ │ damit sie den Speicher tatsächlich nutzen: │ Projektprompt zum Übernehmen, slug-Regeln, │ Muster für einzelne Aufgaben. +├── BETRIEB.md ← Betriebshandbuch für den Ernstfall: Server startet +│ nicht, Platte voll, Key oder Geheimnis geleakt, +│ Wiederherstellen aus der Sicherung, Updates. ├── AUSBAUSTUFEN.md ← Was bewusst nicht gebaut wurde: Messwerte, │ Begründung und die Auslöser, an denen man │ merkt, dass es Zeit dafür wird. +├── LASTTEST.md ← Lasttest: Ausführung, Schalter, Ergebnisse mit +│ Datum und ihre Grenzen. ├── REPOMAP.md ← Diese Datei. │ ├── internal/ @@ -168,7 +175,9 @@ munin/ │ │ │ │ │ ├── session.go ← Signierte Sitzungs-Cookies (HMAC-SHA256). Der │ │ │ Cookie trägt nur eine Behauptung ("ich bin Key X"), -│ │ │ nie den Key selbst. +│ │ │ nie den Key selbst. Die Sitzung des Plattform- +│ │ │ Admins trägt einen Fingerabdruck des ADMIN_TOKEN, +│ │ │ damit ein neues Token sie sofort beendet. │ │ │ │ │ ├── csrf.go ← CSRF-Tokens aus Sitzung und Geheimnis, ohne Zustand │ │ │ auf dem Server. Der Kopfkommentar erklärt den @@ -214,17 +223,21 @@ munin/ │ │ "?"-Links der Oberfläche darauf zeigen können. │ │ │ ├── export/ ── Sicherung als Markdown ───────────────────────── -│ │ └── export.go ← Schreibt jeden Workspace als Ordner -│ │ (/, mit _workspace.md) und darin -│ │ jedes Projekt als Ordner voller -│ │ Markdown-Dateien, mit allen Feldern im Dateikopf. -│ │ Kennt selbst keine Datenbank: Der Aufrufer gibt -│ │ die Workspace-Datei je Lauf mit. -│ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne -│ │ inhaltliche Änderungen fasst keine Datei an und -│ │ erzeugt deshalb auch keinen Diff. Verwaiste -│ │ Dateien gelöschter Einträge werden entfernt, -│ │ fremde Dateien im Zielordner bleiben unberührt. +│ │ ├── export.go ← Schreibt jeden Workspace als Ordner +│ │ │ (/, mit _workspace.md) und darin +│ │ │ jedes Projekt als Ordner voller +│ │ │ Markdown-Dateien, mit allen Feldern im Dateikopf. +│ │ │ Kennt selbst keine Datenbank: Der Aufrufer gibt +│ │ │ die Workspace-Datei je Lauf mit. +│ │ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne +│ │ │ inhaltliche Änderungen fasst keine Datei an und +│ │ │ erzeugt deshalb auch keinen Diff. Verwaiste +│ │ │ Dateien gelöschter Einträge werden entfernt, +│ │ │ fremde Dateien im Zielordner bleiben unberührt. +│ │ └── zip.go ← Dieselben Dateien als ZIP für den Download. +│ │ Erst alles aus der Datenbank sammeln, dann +│ │ packen — sonst bekäme der Browser bei einem +│ │ Fehler ein halbes Archiv statt einer Fehlerseite. │ │ │ ├── importer/ ── Gegenstück zum Export ────────────────────────── │ │ ├── archiv.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit @@ -250,6 +263,11 @@ munin/ │ │ Indizes statt eines gemeinsamen mit Filter: So wird │ │ die Abschottung strukturell, und die BM25-Werte │ │ werden nicht über Projektgrenzen hinweg verzerrt. +│ │ Refresh liest nach jedem Schreiben aus der +│ │ Datenbank nach, damit sich überholende Schreiber +│ │ keinen alten Stand im Index hinterlassen. Jedes +│ │ Projekt hat eine eigene Sperre, damit ein langer +│ │ Aufbau nicht den ganzen Workspace aufhält. │ │ │ ├── hilfe/ ── Eingebaute Onboarding-Hilfe ─────────────────── │ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel) und @@ -263,7 +281,9 @@ munin/ │ ├── web/ ── HTTP: Browser und API ────────────────────────── │ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die │ │ │ Ablage im Request-Context. CanAccessProject ist -│ │ │ die Mandantentrennung der Weboberfläche. +│ │ │ die Mandantentrennung der Weboberfläche. Die +│ │ │ Can…-Methoden sind die einzige Stelle, an der +│ │ │ der Workspace-Admin Rechte bekommt. │ │ │ │ │ ├── middleware.go ← Sitzungsprüfung (inkl. "gilt der Key noch?"), │ │ │ CSRF, Projektauflösung aus der URL, Rollenprüfung. @@ -287,9 +307,18 @@ munin/ │ │ │ Dazu Vorschau (preview), Vorschläge für [[…]] │ │ │ (suggest), Fassungen und Vergleich, Graph-Daten │ │ │ und Schlagworte für viele Einträge (bulk-tags). +│ │ ├── handlers_tags.go ← Seite "Alle Schlagworte" (/p/{projekt}/tags), +│ │ │ nach Häufigkeit oder alphabetisch gruppiert. Die +│ │ │ Seitenleiste zeigt nur die häufigsten (navTagLimit +│ │ │ in server.go). │ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage), │ │ │ für jede Rolle. Die Admin-Projektliste nutzt │ │ │ dieselben Zahlen (projectListItems). +│ │ ├── handlers_download.go +│ │ │ ← Download als ZIP: ein Projekt (/p/{projekt}/ +│ │ │ export.zip, wer darf: Session.CanDownloadProject) +│ │ │ oder der ganze Workspace (/admin/export.zip). +│ │ │ Unabhängig von EXPORT_DIR, nichts bleibt liegen. │ │ ├── handlers_import.go ← ZIP-Upload für den Importer (/p/{projekt}/import), │ │ │ nur mit Rolle admin. Läuft im Server, damit der │ │ │ Suchindex im selben Zug nachgezogen wird. @@ -307,6 +336,11 @@ munin/ │ │ │ löschen (nur Plattform-Admin). Löscht jemand den │ │ │ Workspace der eigenen Sitzung, wird die Auswahl │ │ │ zurückgesetzt, statt ihn auszusperren. +│ │ ├── handlers_workspace_admins.go +│ │ │ ← Workspace-Admins (wa_…, AUSBAUSTUFEN 4.8) +│ │ │ anlegen und zurückziehen, nur durch den +│ │ │ Plattform-Admin, auf der Bearbeitungsseite des +│ │ │ Workspace. │ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht, Export. Die │ │ Bearbeitungsseite je Projekt (Name, │ │ Beschreibung, Schreibbudget, Löschen) liegt @@ -346,8 +380,10 @@ munin/ │ ├── autocomplete.js ← Vorschläge nach "[[" im Textfeld, per Tastatur │ │ und Maus bedienbar. │ ├── graph.js ← Selbst geschriebene Kräftesimulation und -│ │ SVG-Darstellung für die Graph-Ansicht. Texte nur -│ │ über textContent, nie innerHTML. +│ │ SVG-Darstellung für die Graph-Ansicht. Zoom zieht +│ │ Knoten auseinander, Namen nur, wo Platz ist. +│ │ Texte nur über textContent, nie innerHTML. +│ ├── tagfilter.js ← Filterfeld auf der Seite "Alle Schlagworte". │ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne │ Hintergrund). Die Anmeldeseite wählt je nach │ Farbschema die passende. @@ -371,6 +407,14 @@ weitere Vorbereitung — die Datenbanktests legen jeweils eine echte SQLite-Date in einem Testverzeichnis an, damit auch Migrationen und Fremdschlüssel geprüft werden. +Ausnahme ist der Lasttest `last_test.go`: Er braucht Zeit und lastet die +Maschine aus und läuft deshalb nur auf Wunsch, mit +`go test -tags last -run TestLast -v .` (Schalter für Agentenzahl, Dauer usw. +oben in der Datei). Er misst über echtes HTTP und MCP drei Aufstellungen (ein +Projekt, mehrere Projekte in einem Workspace, mehrere Workspaces) und prüft am +Ende, dass kein Schreibvorgang verloren ging und der Suchindex aktuell ist. +Ausführung, Schalter und Ergebnisse: `LASTTEST.md`. + | Datei | Schwerpunkt | |---|---| | `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For | @@ -383,9 +427,12 @@ werden. | `internal/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe | | `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen | | `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen | +| `internal/search/registry_test.go` | Index folgt der Datenbank, auch wenn sich Schreiber überholen, ein Eintrag gleich wieder gelöscht wird oder während des Aufbaus geschrieben wird; ein langsamer Aufbau bremst andere Projekte nicht | +| `internal/mcpserver/gleichzeitig_test.go` | Viele Agenten schreiben über MCP gleichzeitig denselben Eintrag: keine verlorene Fassung, Index aktuell | | `internal/store/related_test.go` | Verwandte Einträge über zwei Ebenen, Papierkorb als Sackgasse, Grenze für veraltete Einträge, übernommene Zeitstempel beim Anlegen | -| `internal/importer/importer_test.go` | **Rundlauf Export → ZIP → Import ohne Verlust**, Vorschau, Überspringen/Überschreiben, Papierkorb, doppelte Bezeichner, fremde Köpfe, Grenzen des Archivs | +| `internal/importer/importer_test.go` | **Rundlauf Export → ZIP → Import ohne Verlust**, auch mit dem ZIP aus dem Download, Vorschau, Überspringen/Überschreiben, Papierkorb, doppelte Bezeichner, fremde Köpfe, Grenzen des Archivs | | `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe | +| `internal/web/download_test.go` | ZIP-Download: Inhalt und Protokolleintrag, nur Admins, kein fremdes Projekt, Workspace-ZIP für Plattform- und Workspace-Admin, sichere Dateinamen | | `internal/web/import_test.go` | Import mit Vorschau und Übernehmen, Suchindex nachgezogen, ein Protokolleintrag, nur für admin, Body-Grenze je Route | | `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse | | `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** | @@ -423,7 +470,7 @@ KI-Werkzeug POST /mcp { … "name": "remember", "arguments": { "slug": …, "c · ExpectedVersion prüfen → ggf. Konflikt · Schlagworte und Verweise ersetzen · Herkunft (agent) und Zugangsname setzen - → search/registry.go (Update) Index nachziehen + → search/registry.go (Refresh) Index nachziehen → store/audit.go (LogAudit) wer, was, wann → Antwort: Bezeichner, neue Fassung, angelegt oder aktualisiert ``` @@ -439,7 +486,7 @@ Browser POST /p/plattform/e/deployment/save → web/middleware.go (requireWrite) Rollenprüfung → web/handlers_entries.go (handleEntrySave) → store/entries.go (PutEntry) Herkunft: human, Autor: Key-Name - → search/registry.go (Update) + → search/registry.go (Refresh) → store/audit.go (LogAudit) → Weiterleitung zur Detailansicht ``` diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 0142f7c..69cdb1a 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -187,6 +187,31 @@ func TestLooksLikeKey(t *testing.T) { } } +// TestWorkspaceAdminTokenIsNoKey: Die beiden Arten dürfen sich nie +// überschneiden. Ginge ein Workspace-Admin-Token als API-Key durch, stünde +// ihm die MCP-Schnittstelle offen — und umgekehrt käme ein Projekt-Key an +// die Workspace-Verwaltung. +func TestWorkspaceAdminTokenIsNoKey(t *testing.T) { + token, err := GenerateWorkspaceAdminToken() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(token, WorkspaceAdminPrefix) || !LooksLikeWorkspaceAdminToken(token) { + t.Errorf("selbst erzeugtes Token besteht die Formprüfung nicht: %q", token) + } + if LooksLikeKey(token) { + t.Error("ein Workspace-Admin-Token geht als API-Key durch") + } + + key, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + if LooksLikeWorkspaceAdminToken(key) { + t.Error("ein API-Key geht als Workspace-Admin-Token durch") + } +} + // ── Rollen ─────────────────────────────────────────────────────────────────── func TestRolePermissions(t *testing.T) { @@ -485,3 +510,35 @@ func TestSetTrustedProxiesRejectsGarbage(t *testing.T) { } SetTrustedProxies("") } + +func TestPlatformSubjectHinUndZurueck(t *testing.T) { + faelle := []struct{ fingerabdruck, workspaceID string }{ + {"abc123", ""}, + {"abc123", "ws-1"}, + } + for _, f := range faelle { + subject := PlatformSubject(f.fingerabdruck, f.workspaceID) + fp, ws, ok := ParsePlatformSubject(subject) + if !ok || fp != f.fingerabdruck || ws != f.workspaceID { + t.Errorf("%q → (%q, %q, %v), erwartet (%q, %q, true)", subject, fp, ws, ok, f.fingerabdruck, f.workspaceID) + } + } + + // Alte Formen ohne Fingerabdruck und fremde Subjects sind keine. + for _, subject := range []string{"platform", "platform:ws-1", "platform/", "platform/:ws-1", "key:ws:1"} { + if _, _, ok := ParsePlatformSubject(subject); ok { + t.Errorf("%q wurde als Subject des Plattform-Admins erkannt", subject) + } + } +} + +func TestFingerprintHaengtVomGeheimnisAb(t *testing.T) { + a := NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert") + b := NewSigner([]byte("geheimnis-zwei-geheimnis-zwei-geheimnis")).Fingerprint("wert") + if a == b { + t.Error("gleicher Fingerabdruck trotz anderem SESSION_SECRET") + } + if a != NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert") { + t.Error("Fingerabdruck ist nicht stabil") + } +} diff --git a/internal/auth/doc.go b/internal/auth/doc.go index c48c6a4..05ef1bd 100644 --- a/internal/auth/doc.go +++ b/internal/auth/doc.go @@ -11,8 +11,12 @@ // einmalige Anmeldung mit demselben API-Key und trägt danach nur noch eine // signierte Behauptung ("ich bin Key X"), nie den Key selbst. // -// Dazu kommt der Plattform-Admin (Umgebungsvariable ADMIN_TOKEN), der Projekte -// anlegen und löschen darf — der einzige Zugang, der nicht an ein Projekt gebunden ist. +// Dazu kommen zwei Verwaltungszugänge, die nicht an ein Projekt gebunden sind +// und nur für die Web-Oberfläche gelten: +// +// - der Plattform-Admin (Umgebungsvariable ADMIN_TOKEN) für alle Workspaces, +// - Workspace-Admins ("wa_…", AUSBAUSTUFEN.md 4.8), die in genau einem +// Workspace Projekte und Keys verwalten. // // # Was hier NICHT liegt // diff --git a/internal/auth/keys.go b/internal/auth/keys.go index bb120b5..1d021f0 100644 --- a/internal/auth/keys.go +++ b/internal/auth/keys.go @@ -17,6 +17,15 @@ import ( // Secret Scanning und ähnliche) suchen nach genau solchen Präfixen. const KeyPrefix = "wk_" +// WorkspaceAdminPrefix steht vor jedem Token eines Workspace-Admins +// (AUSBAUSTUFEN.md 4.8). +// +// Das eigene Präfix ist mehr als Kosmetik: APIAuth lässt nur zu, was +// LooksLikeKey erkennt. Ein Workspace-Admin-Token scheitert dort schon an der +// Form und kommt so nie an die MCP-Schnittstelle — der Zugang ist ausdrücklich +// nur für die Weboberfläche gedacht. +const WorkspaceAdminPrefix = "wa_" + // keyBytes ist die Länge des Zufallsanteils eines Keys. // 32 Byte = 256 Bit — weit jenseits von allem, was sich durchprobieren lässt. const keyBytes = 32 @@ -64,11 +73,23 @@ func (r Role) CanManageKeys() bool { // Der Klartext existiert nur genau einmal — direkt nach dem Erzeugen wird er dem // Nutzer angezeigt und danach nirgends gespeichert (siehe HashKey). func GenerateKey() (string, error) { + return generateWithPrefix(KeyPrefix) +} + +// GenerateWorkspaceAdminToken erzeugt das Token eines Workspace-Admins im +// Klartext. Aufbau und Stärke wie bei GenerateKey, nur mit eigenem Präfix — +// siehe WorkspaceAdminPrefix, warum das wichtig ist. +func GenerateWorkspaceAdminToken() (string, error) { + return generateWithPrefix(WorkspaceAdminPrefix) +} + +// generateWithPrefix erzeugt keyBytes Zufallsbytes und stellt das Präfix davor. +func generateWithPrefix(prefix string) (string, error) { buf := make([]byte, keyBytes) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("Zufallszahlen konnten nicht erzeugt werden: %w", err) } - return KeyPrefix + hex.EncodeToString(buf), nil + return prefix + hex.EncodeToString(buf), nil } // HashKey bildet den Speicher-Hash eines API-Keys. @@ -98,10 +119,21 @@ func HashKey(key string) string { // Das ist keine Sicherheitsprüfung, sondern spart einen Datenbankzugriff bei // offensichtlichem Unsinn (leerer Header, abgeschnittener Key, Tippfehler). func LooksLikeKey(key string) bool { - if !strings.HasPrefix(key, KeyPrefix) { + return looksLike(key, KeyPrefix) +} + +// LooksLikeWorkspaceAdminToken prüft die äußere Form eines +// Workspace-Admin-Tokens — Gegenstück zu LooksLikeKey. +func LooksLikeWorkspaceAdminToken(token string) bool { + return looksLike(token, WorkspaceAdminPrefix) +} + +// looksLike prüft Präfix, Länge und Hex-Form. +func looksLike(key, prefix string) bool { + if !strings.HasPrefix(key, prefix) { return false } - rest := key[len(KeyPrefix):] + rest := key[len(prefix):] if len(rest) != keyBytes*2 { return false } diff --git a/internal/auth/session.go b/internal/auth/session.go index b2ea629..b943bd3 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -22,12 +22,17 @@ const SessionCookieName = "munin_session" // SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt. const SessionTTL = 30 * 24 * time.Hour -// SubjectPlatformAdmin ist das Session-Subject des Plattform-Admins (ADMIN_TOKEN). +// SubjectPlatformAdmin ist der Anfang des Session-Subjects des Plattform-Admins +// (ADMIN_TOKEN). Das vollständige Subject baut PlatformSubject. const SubjectPlatformAdmin = "platform" // SubjectKeyPrefix steht vor der Key-ID im Session-Subject einer normalen Anmeldung. const SubjectKeyPrefix = "key:" +// SubjectWorkspaceAdminPrefix steht vor ":" im +// Session-Subject eines Workspace-Admins. +const SubjectWorkspaceAdminPrefix = "wsadmin:" + // Signer signiert und prüft alles, was der Server einem Browser mitgibt: // Session-Cookies und CSRF-Tokens. // @@ -55,7 +60,8 @@ func (s *Signer) sign(payload string) string { // SignSession erstellt den Cookie-Wert für ein Subject. // -// subject ist entweder SubjectPlatformAdmin oder "key:". +// subject stammt aus PlatformSubject oder beginnt mit SubjectKeyPrefix bzw. +// SubjectWorkspaceAdminPrefix. // // Aufbau: base64("|") + "." + HMAC // @@ -102,6 +108,53 @@ func (s *Signer) VerifySession(cookieValue string) (subject string, ok bool) { return subject, true } +// Fingerprint leitet aus einem Wert einen kurzen, nicht umkehrbaren +// Fingerabdruck ab, der nur mit dem SESSION_SECRET zu berechnen ist. +// +// Gebraucht wird er für die Sitzung des Plattform-Admins (siehe +// PlatformSubject). Der Cookie ist lesbar, deshalb steht darin nicht der Hash +// des ADMIN_TOKEN selbst, sondern nur dieser Abdruck davon. +func (s *Signer) Fingerprint(value string) string { + return s.sign("fingerabdruck|" + value)[:16] +} + +// PlatformSubject baut das Session-Subject des Plattform-Admins: +// +// "platform/" noch kein Workspace gewählt +// "platform/:" mit gewähltem Workspace +// +// # Warum ein Fingerabdruck des ADMIN_TOKEN im Subject? +// +// Bei Keys und Workspace-Admins prüft der Server bei jedem Request in der +// Datenbank, ob der Zugang noch gilt. Beim Plattform-Admin gibt es keinen +// Datensatz, nur die Umgebungsvariable. Früher stand im Subject einfach +// "platform", und eine Sitzung blieb gültig, solange die Signatur stimmte. +// Wer sich mit einem geleakten ADMIN_TOKEN angemeldet hatte, blieb deshalb +// bis zu 30 Tage angemeldet, auch nachdem das Token längst ersetzt war. +// +// Mit dem Fingerabdruck vergleicht der Server bei jedem Request, ob die Sitzung +// mit dem *aktuellen* ADMIN_TOKEN entstanden ist. Ein neues Token beendet so +// sofort alle Admin-Sitzungen, während Teams mit ihren Keys angemeldet bleiben. +func PlatformSubject(fingerprint, workspaceID string) string { + subject := SubjectPlatformAdmin + "/" + fingerprint + if workspaceID != "" { + subject += ":" + workspaceID + } + return subject +} + +// ParsePlatformSubject zerlegt ein Subject aus PlatformSubject. ok ist false, +// wenn es kein Subject des Plattform-Admins ist, auch bei der alten Form ohne +// Fingerabdruck. workspaceID ist leer, solange kein Workspace gewählt ist. +func ParsePlatformSubject(subject string) (fingerprint, workspaceID string, ok bool) { + rest, ok := strings.CutPrefix(subject, SubjectPlatformAdmin+"/") + if !ok || rest == "" { + return "", "", false + } + fingerprint, workspaceID, _ = strings.Cut(rest, ":") + return fingerprint, workspaceID, fingerprint != "" +} + // KeyIDFromSubject holt die API-Key-ID aus einem Session-Subject. // Beim Plattform-Admin gibt es keine — dann ist ok false. func KeyIDFromSubject(subject string) (keyID string, ok bool) { diff --git a/internal/export/export.go b/internal/export/export.go index 542b6df..e0bba03 100644 --- a/internal/export/export.go +++ b/internal/export/export.go @@ -86,19 +86,16 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st var stats Stats // Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim - // Projekt-Bezeichner direkt darunter. IDs aus store.NewID bestehen nur aus - // Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese Prüfung. + // Projekt-Bezeichner (siehe projektDateien). IDs aus store.NewID bestehen + // nur aus Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese + // Prüfung. if !store.ValidSlug(project.WorkspaceID) { return stats, fmt.Errorf("ungültige Workspace-ID %q", project.WorkspaceID) } - // Der Projekt-Bezeichner ist beim Anlegen geprüft worden (siehe - // store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier - // noch einmal, weil ein Wert aus der Datenbank grundsätzlich nichts - // garantiert — ein späterer Fehler an anderer Stelle soll nicht dazu führen, - // dass hier außerhalb des Zielordners geschrieben wird. - if !store.ValidSlug(project.Slug) { - return stats, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug) + dateien, err := projektDateien(ctx, s, project) + if err != nil { + return stats, err } ziel := filepath.Join(e.root, project.WorkspaceID, project.Slug) @@ -106,35 +103,20 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err) } - eintraege, err := s.AllEntries(ctx, project.ID) - if err != nil { - return stats, err - } - // Namen merken, um danach zu wissen, was übrig bleibt. - erwartet := make(map[string]bool, len(eintraege)+1) - erwartet[indexDateiname] = true - - for _, eintrag := range eintraege { - if !store.ValidSlug(eintrag.Slug) { - return stats, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug) - } - - name := eintrag.Slug + ".md" + erwartet := make(map[string]bool, len(dateien)) + for _, d := range dateien { + // Im Ordner des Projekts zählt nur der Dateiname, ohne den + // Projektordner davor. + name := filepath.Base(d.Pfad) erwartet[name] = true - if err := schreibeDatei(filepath.Join(ziel, name), rendere(eintrag)); err != nil { + if err := schreibeDatei(filepath.Join(ziel, name), d.Inhalt); err != nil { return stats, err } stats.Written++ } - // Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist. - if err := schreibeDatei(filepath.Join(ziel, indexDateiname), rendereIndex(project, eintraege)); err != nil { - return stats, err - } - stats.Written++ - entfernt, err := raeumeAuf(ziel, erwartet) if err != nil { return stats, err @@ -181,6 +163,50 @@ func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store return gesamt, nil } +// projektDateien stellt alle Dateien eines Projekts zusammen, ohne etwas zu +// schreiben: je Eintrag eine Datei und die Übersicht _index.md, alle unter +// "/". +// +// Export auf die Platte und Download als ZIP (siehe zip.go) bauen beide +// hierauf auf. So legt nur eine Stelle fest, wie ein Export aussieht — und +// ein heruntergeladenes ZIP passt genauso in den Import wie ein Ordner aus +// EXPORT_DIR. +func projektDateien(ctx context.Context, s *store.Store, project store.Project) ([]Datei, error) { + // Der Projekt-Bezeichner ist beim Anlegen geprüft worden (siehe + // store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier + // noch einmal, weil ein Wert aus der Datenbank grundsätzlich nichts + // garantiert — ein späterer Fehler an anderer Stelle soll nicht dazu führen, + // dass außerhalb des Zielordners geschrieben wird. + if !store.ValidSlug(project.Slug) { + return nil, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug) + } + + eintraege, err := s.AllEntries(ctx, project.ID) + if err != nil { + return nil, err + } + + dateien := make([]Datei, 0, len(eintraege)+1) + for _, eintrag := range eintraege { + if !store.ValidSlug(eintrag.Slug) { + return nil, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug) + } + dateien = append(dateien, Datei{ + Pfad: project.Slug + "/" + eintrag.Slug + ".md", + Inhalt: rendere(eintrag), + Geaendert: eintrag.UpdatedAt, + }) + } + + // Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist. + dateien = append(dateien, Datei{ + Pfad: project.Slug + "/" + indexDateiname, + Inhalt: rendereIndex(project, eintraege), + }) + + return dateien, nil +} + // indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird. const indexDateiname = "_index.md" diff --git a/internal/export/zip.go b/internal/export/zip.go new file mode 100644 index 0000000..a0ee4d4 --- /dev/null +++ b/internal/export/zip.go @@ -0,0 +1,101 @@ +package export + +import ( + "archive/zip" + "context" + "fmt" + "io" + "time" + + "munin/internal/store" +) + +// Datei ist eine Datei des Exports, noch nicht geschrieben. +type Datei struct { + // Pfad ist relativ zur Wurzel des Exports und nutzt immer "/", auch unter + // Windows — so verlangt es das ZIP-Format. + Pfad string + Inhalt string + + // Geaendert ist der Zeitpunkt, den die Datei im ZIP trägt. Bei Einträgen + // ihre letzte Änderung, damit ein entpacktes Archiv in jedem Dateimanager + // die richtige Reihenfolge zeigt. Leer bedeutet: Zeitpunkt des Downloads. + Geaendert time.Time +} + +// ProjektDateien stellt den Export eines einzelnen Projekts zusammen: +// +// / +// _index.md +// .md +// +// Das ist genau das, was der Import eines Projekts erwartet (Unterordner +// erlaubt, Dateien mit "_" am Anfang werden übergangen). +func ProjektDateien(ctx context.Context, s *store.Store, project store.Project) ([]Datei, error) { + return projektDateien(ctx, s, project) +} + +// WorkspaceDateien stellt den Export aller Projekte eines Workspace zusammen, +// im selben Aufbau wie ein Workspace-Ordner unter EXPORT_DIR: +// +// _workspace.md +// /… +// /… +func WorkspaceDateien(ctx context.Context, s *store.Store, ws store.Workspace) ([]Datei, error) { + dateien := []Datei{{Pfad: workspaceDateiname, Inhalt: rendereWorkspace(ws)}} + + projekte, err := s.ListProjects(ctx, ws.ID) + if err != nil { + return nil, err + } + + for _, p := range projekte { + projekt, err := projektDateien(ctx, s, p) + if err != nil { + return nil, fmt.Errorf("Projekt %q: %w", p.Slug, err) + } + dateien = append(dateien, projekt...) + } + return dateien, nil +} + +// SchreibeZIP packt die Dateien als ZIP-Archiv nach w. +// +// # Warum erst sammeln, dann packen? +// +// Beim Download schreibt w direkt in die Antwort an den Browser. Ist der +// erste Teil des Archivs einmal unterwegs, lässt sich ein Fehler nicht mehr +// als Fehlerseite melden — der Browser bekäme nur ein kaputtes ZIP. Deshalb +// liest der Aufrufer zuerst alles aus der Datenbank (ProjektDateien, +// WorkspaceDateien). Was danach noch schiefgehen kann, ist nur noch die +// Verbindung selbst. +func SchreibeZIP(w io.Writer, dateien []Datei) error { + jetzt := time.Now() + archiv := zip.NewWriter(w) + + for _, d := range dateien { + geaendert := d.Geaendert + if geaendert.IsZero() { + geaendert = jetzt + } + + // CreateHeader statt Create, um Komprimierung und Zeitstempel + // selbst festzulegen. Deflate ist das Verfahren, das jedes + // Entpackprogramm kennt. + f, err := archiv.CreateHeader(&zip.FileHeader{ + Name: d.Pfad, + Method: zip.Deflate, + Modified: geaendert, + }) + if err != nil { + return err + } + if _, err := io.WriteString(f, d.Inhalt); err != nil { + return err + } + } + + // Close schreibt das Inhaltsverzeichnis ans Ende des Archivs. Ohne diesen + // Schritt ist das ZIP unbrauchbar, auch wenn alle Dateien darin stehen. + return archiv.Close() +} diff --git a/internal/hilfe/seiten/begriffe.md b/internal/hilfe/seiten/begriffe.md index 6278d88..a096385 100644 --- a/internal/hilfe/seiten/begriffe.md +++ b/internal/hilfe/seiten/begriffe.md @@ -84,5 +84,11 @@ Jeder Key hat genau eine Rolle. | `write` | zusätzlich schreiben und löschen | der Normalfall für Menschen und beaufsichtigte Agenten | | `admin` | zusätzlich die Zugänge des Projekts verwalten | Menschen, die für das Projekt verantwortlich sind | -Darüber steht der **Plattform-Admin**. Er meldet sich mit einem eigenen -Admin-Token an, legt Workspaces und Projekte an und kommt an jedes Projekt. +Darüber stehen zwei Verwaltungszugänge, die nicht an ein Projekt gebunden +sind und nur für die Weboberfläche gelten: + +- Der **Workspace-Admin** verwaltet in genau einem Workspace die Projekte und + ihre Zugänge und kann alle Projekte dort lesen (siehe + [Zugänge und Sicherheit](/hilfe/zugaenge#workspace-admin)). +- Der **Plattform-Admin** meldet sich mit einem eigenen Admin-Token an, legt + Workspaces und Projekte an und kommt an jedes Projekt. diff --git a/internal/hilfe/seiten/plattform-admin.md b/internal/hilfe/seiten/plattform-admin.md index 54a984b..9f46a0c 100644 --- a/internal/hilfe/seiten/plattform-admin.md +++ b/internal/hilfe/seiten/plattform-admin.md @@ -12,6 +12,10 @@ weitere Keys für ihr Team und ihre Agenten an. Der Verweis auf diese Hilfe (`{{SERVER}}/hilfe`) reicht als Einstieg. +Soll eine Abteilung ihre Projekte selbst anlegen, reicht es, ihr einen +[Workspace-Admin](#workspace-admins) einzurichten. Die Schritte 2 und 3 +übernimmt dann sie. + ## Workspaces | Aktion | Wo | @@ -27,6 +31,32 @@ auf diesen Workspace. Einträgen und Keys. Zur Bestätigung muss der Name eingetippt werden. Rückgängig machen lässt sich das nur aus einer Sicherung. +## Workspace-Admins + +Ein Workspace-Admin legt im eigenen Workspace Projekte an, bearbeitet und +löscht sie und verwaltet deren Zugänge, nur über die Weboberfläche. Was er +genau darf und was nicht, steht unter +[Zugänge und Sicherheit](/hilfe/zugaenge#workspace-admin). + +**Anlegen:** *Workspaces* → *Bearbeiten* → Abschnitt *Workspace-Admins*. Den +Namen der Person eintragen. Das Token (beginnt mit `wa_`) wird genau einmal +angezeigt und muss der Person sicher übergeben werden. Mit ihm meldet sie sich +an derselben Stelle an wie mit einem Key. + +**Zurückziehen:** am selben Ort. Das Token gilt sofort nicht mehr, auch eine +laufende Sitzung endet. Beim Löschen des Workspace verschwinden seine +Workspace-Admins mit. + +**Ungenutzte Zugänge:** Wie bei den Keys trägt ein aktiver Workspace-Admin, +der seit über 90 Tagen nicht benutzt wurde, die Marke *ungenutzt*. Die +Übersicht unter *Workspaces* zeigt je Workspace, wie viele Workspace-Admins +aktiv sind und wie viele davon ungenutzt. Ein Klick auf die Marke führt zur +Bearbeitungsseite. Ein vergessenes Token öffnet den ganzen Workspace, deshalb +im Zweifel zurückziehen. + +Anlegen und Zurückziehen erscheinen im *Protokoll (alle)* des betroffenen +Workspace, ebenso jede Anmeldung eines Workspace-Admins. + ## Projekte Unter *Projekte → Verwalten…* stehen alle Projekte des Workspace mit @@ -34,8 +64,9 @@ Eintragszahl und dem Stand ihres Schreibbudgets. Über *Bearbeiten* lassen sich Name, Beschreibung und die **eigene Obergrenze fürs Schreibbudget** ändern. Dort liegt auch das Löschen. -Das Schreibbudget kann nur der Plattform-Admin ändern. Sonst könnte ein Team -die Grenze selbst aufheben, die gerade vor durchdrehenden Agenten schützen soll. +Das Schreibbudget kann nur der Plattform-Admin ändern, auch ein +Workspace-Admin nicht. Sonst könnte ein Team die Grenze selbst aufheben, die +gerade vor durchdrehenden Agenten schützen soll. ## Protokoll über alle Projekte @@ -54,7 +85,13 @@ zusätzliche, lesbare Sicherung, etwa für ein Git-Repository. Unter *Projekte* exportiert der Knopf **Jetzt exportieren** sofort den gerade gewählten Workspace, zum Beispiel vor einer größeren Änderung. +Ohne Exportverzeichnis und ohne Zugriff auf den Server geht es über den +Download: **Alle als ZIP herunterladen** unter *Projekte* liefert alle +Projekte des gewählten Workspace in einem Archiv, *Als ZIP herunterladen* über +der Eintragsliste ein einzelnes Projekt. Beides dürfen auch Workspace-Admins. Jeder +Download steht im Protokoll. + Zurück kommt ein Export über den [Import](/hilfe/weboberflaeche#import) im -jeweiligen Projekt: den Projektordner aus dem Export als ZIP-Archiv packen und -hochladen. So lässt sich auch ein einzelnes Projekt wiederherstellen, ohne +jeweiligen Projekt: das ZIP eines Projekts direkt hochladen, oder den +Projektordner aus dem Exportverzeichnis als ZIP-Archiv packen und hochladen. So lässt sich auch ein einzelnes Projekt wiederherstellen, ohne die ganze Workspace-Datei zurückzuspielen. diff --git a/internal/hilfe/seiten/weboberflaeche.md b/internal/hilfe/seiten/weboberflaeche.md index be3517f..0893411 100644 --- a/internal/hilfe/seiten/weboberflaeche.md +++ b/internal/hilfe/seiten/weboberflaeche.md @@ -14,14 +14,19 @@ zurückgezogen, endet auch die Sitzung im Browser sofort. Schlagworte und Inhalt. Titel zählen am stärksten. Umlaute sind egal: „Uberblick“ findet auch „Überblick“. Ein Wortanfang reicht, „deploy“ findet auch „Deployment“. -- **Nach Schlagwort filtern:** In der Seitenleiste stehen alle Schlagworte des - Projekts, die häufigsten zuerst. Ein Klick filtert die Liste. +- **Nach Schlagwort filtern:** In der Seitenleiste stehen die zehn häufigsten + Schlagworte des Projekts. Ein Klick filtert die Liste. Gibt es mehr, führt + *Alle Schlagworte…* zu einer Seite mit der vollständigen Liste, nach + Häufigkeit oder alphabetisch sortiert und mit einem Filterfeld. - **Verweisen folgen:** Unter jedem Eintrag steht, welche anderen Einträge auf ihn verweisen. - **Graph:** Unter *Graph* in der Seitenleiste zeigt Munin, wie die Einträge über Verweise zusammenhängen. Gestrichelte Knoten sind Verweisziele, zu denen es noch keinen Eintrag gibt. Mausrad zoomt, Ziehen verschiebt, ein Klick - öffnet den Eintrag. Bei sehr großen Projekten zeigt der Graph nur die 150 am + öffnet den Eintrag. Beschriftet sind zunächst nur die wichtigsten Knoten; + beim Hineinzoomen rücken die Knoten auseinander und weitere Namen + erscheinen. Zeigt die Maus auf einen Knoten, treten er und seine direkten + Nachbarn hervor. Bei sehr großen Projekten zeigt der Graph nur die 150 am stärksten verknüpften Einträge. ## Schreiben @@ -155,8 +160,9 @@ Mit der Rolle `admin` gibt es in der Seitenleiste den Punkt *Import*. Dort lässt sich ein ZIP-Archiv mit Markdown-Dateien hochladen. Jede Datei wird ein Eintrag. Das passt für drei Fälle: -- **Wiederherstellen** aus dem Markdown-Export: den Ordner eines Projekts - packen und hochladen. Schlagworte, Autor, Herkunft, Prüfvermerk und +- **Wiederherstellen** aus dem Markdown-Export: das ZIP aus *Als ZIP herunterladen* + direkt hochladen, oder den Ordner eines Projekts aus dem Exportverzeichnis + des Servers packen und hochladen. Schlagworte, Autor, Herkunft, Prüfvermerk und Änderungsdatum bleiben erhalten. - **Umzug** eines Projekts auf einen anderen Server oder in einen anderen Workspace, auf demselben Weg. @@ -177,6 +183,18 @@ dasselbe Archiv ohne den Haken noch einmal hochladen. Nicht übernommen wird der Verlauf, denn der Export enthält nur den aktuellen Stand. Ein importierter Eintrag beginnt deshalb wieder bei Fassung 1. +## Export als ZIP + +Mit der Rolle `admin` steht über der Eintragsliste der Knopf *Als ZIP +herunterladen*. Ein Klick lädt alle Einträge des Projekts als ZIP-Archiv herunter: je Eintrag +eine Markdown-Datei mit allen Feldern im Kopf, dazu eine Übersicht +`_index.md`. Der Papierkorb und der Verlauf älterer Fassungen sind nicht +enthalten. + +Das Archiv passt unverändert in den [Import](#import), in dieses oder ein +anderes Projekt, auch auf einem anderen Server. Jeder Download steht im +Protokoll. + ## Protokoll Unter *Protokoll* in der Seitenleiste steht, wer im Projekt wann etwas diff --git a/internal/hilfe/seiten/zugaenge.md b/internal/hilfe/seiten/zugaenge.md index a35875e..2cbdcff 100644 --- a/internal/hilfe/seiten/zugaenge.md +++ b/internal/hilfe/seiten/zugaenge.md @@ -24,6 +24,33 @@ das Team gewechselt haben, oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang. Wenn niemand mehr weiß, wofür er da ist, am besten zurückziehen. +## Workspace-Admin + +Ein Workspace-Admin kümmert sich um die Projekte einer ganzen Abteilung, ohne +dafür Plattform-Admin sein zu müssen. Er meldet sich mit einem eigenen Token +(beginnt mit `wa_`) in der Weboberfläche an und landet direkt unter +*Projekte → Verwalten…*. + +| Darf | Darf nicht | +|---|---| +| Projekte des eigenen Workspace anlegen, umbenennen und löschen | Einträge schreiben, löschen oder importieren | +| Zugänge aller Projekte dort anlegen und zurückziehen | das Schreibbudget ändern oder auf die Platte des Servers exportieren | +| alle Projekte dort lesen, samt Protokoll und Nutzung | andere Workspaces sehen | +| einzelne Projekte oder den ganzen Workspace als ZIP herunterladen | | +| | sich über MCP verbinden (das Token taugt dafür nicht) | + +Einträge ändern Teams über ihre eigenen Keys, damit im Protokoll eindeutig +steht, wer etwas geschrieben hat. Ein Workspace-Admin, der selbst mitarbeiten +will, legt sich dafür einen Key im jeweiligen Projekt an. + +Weil er Zugänge anlegen darf, kann ein Workspace-Admin sich faktisch Zugriff +auf jedes Projekt seines Workspace verschaffen. Die Rolle gehört deshalb nur an +Personen, denen die Abteilung ohnehin alle ihre Projekte anvertraut. Was er +tut, steht mit seinem Namen im Protokoll. + +Workspace-Admins legt der Plattform-Admin an und zieht sie zurück. Ein +zurückgezogenes Token gilt sofort nicht mehr, auch eine laufende Sitzung endet. + ## Welche Rolle für wen? | Wer | Empfohlene Rolle | diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index e76d1f5..e4538e2 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -144,6 +144,56 @@ func TestRoundTripFromExport(t *testing.T) { } } +// TestRoundTripFromDownload: Das ZIP aus dem Download (export.SchreibeZIP) +// muss ohne Umweg wieder in den Import passen — genau dafür ist es da. +func TestRoundTripFromDownload(t *testing.T) { + ctx := context.Background() + quelle, qp := testStore(t, "quelle") + + for _, w := range []store.EntryWrite{ + {Slug: "deployment", Title: "Deployment", Content: "siehe [[rollback]]", Tags: []string{"ops"}, Source: store.SourceAgent, Author: "agent-1"}, + {Slug: "rollback", Title: "Rollback", Content: "Zurück.", Source: store.SourceHuman, Author: "anna"}, + } { + w.ProjectID = qp.ID + if _, err := quelle.PutEntry(ctx, w); err != nil { + t.Fatal(err) + } + } + + dateien, err := export.ProjektDateien(ctx, quelle, qp) + if err != nil { + t.Fatal(err) + } + var zip bytes.Buffer + if err := export.SchreibeZIP(&zip, dateien); err != nil { + t.Fatal(err) + } + + gelesen, err := LeseZip(zip.Bytes()) + if err != nil { + t.Fatal(err) + } + ziel, zp := testStore(t, "ziel") + bericht, err := Importiere(ctx, ziel, zp.ID, gelesen, Optionen{Modus: ModusUeberspringen, Autor: "importeur"}) + if err != nil { + t.Fatal(err) + } + if bericht.Neu != 2 || bericht.Fehler != 0 { + t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen) + } + + for _, slug := range []string{"deployment", "rollback"} { + a, _ := quelle.EntryBySlug(ctx, qp.ID, slug) + b, err := ziel.EntryBySlug(ctx, zp.ID, slug) + if err != nil { + t.Fatalf("%s fehlt nach dem Import: %v", slug, err) + } + if a.Title != b.Title || a.Content != b.Content || !slices.Equal(a.Tags, b.Tags) || a.Author != b.Author { + t.Errorf("%s nach dem Import verändert:\nvorher %+v\nnachher %+v", slug, a, b) + } + } +} + func TestConflictModes(t *testing.T) { ctx := context.Background() s, p := testStore(t, "test") diff --git a/internal/mcpserver/gleichzeitig_test.go b/internal/mcpserver/gleichzeitig_test.go new file mode 100644 index 0000000..c80c0a6 --- /dev/null +++ b/internal/mcpserver/gleichzeitig_test.go @@ -0,0 +1,159 @@ +package mcpserver + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "munin/internal/auth" + "munin/internal/store" + "munin/internal/web" + "munin/internal/workspace" +) + +// verbinde öffnet eine MCP-Sitzung, über die man mehrere Aufrufe schicken kann. +// +// Anders als ruf meldet es Fehler zurück, statt t.Fatalf aufzurufen — t.Fatalf +// darf nur aus der Goroutine des Tests selbst kommen, nicht aus den +// nebenläufigen Agenten unten. +func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name string) (*mcp.ClientSession, func(), error) { + sess := web.Session{ + Subject: "key:" + ws.ID + ":" + name, + WorkspaceID: ws.ID, + Key: store.APIKey{ID: name, Name: name, ProjectID: projekt.ID, Role: string(auth.RoleWrite)}, + Project: projekt, + Role: auth.RoleWrite, + } + ctx := web.WithSessionForTest(context.Background(), sess) + ctx = web.WithWorkspaceForTest(ctx, ws) + + clientTransport, serverTransport := mcp.NewInMemoryTransports() + serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil) + if err != nil { + return nil, nil, err + } + client := mcp.NewClient(&mcp.Implementation{Name: name, Version: "1"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + serverSession.Close() + return nil, nil, err + } + schliessen := func() { + clientSession.Close() + serverSession.Close() + } + return clientSession, schliessen, nil +} + +// TestGleichzeitigesSchreibenHaeltSuchindexAktuell prüft, dass der Suchindex +// nach gleichzeitigen Schreibvorgängen auf denselben Eintrag den Stand der +// Datenbank kennt. +// +// Die Gefahr: remember schreibt erst in die Datenbank und aktualisiert danach +// den Index — zwei getrennte Schritte. Überholen sich zwei Agenten zwischen +// diesen Schritten, landet Fassung 3 in der Datenbank, aber Fassung 2 im Index. +// recall fände den Eintrag dann bis zum Neustart über veraltete Wörter. +// +// Jeder Schreibvorgang bekommt deshalb ein eigenes, eindeutiges Wort. Am Ende +// muss das Wort der letzten Fassung in der Datenbank auch im Index zu finden +// sein. +func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) { + h, ws, projekt := aufbauMitBudget(t, 100000) + ctx := context.Background() + + // Den Index vorher anlegen. Ohne ihn wäre Search.Update ein No-op und der + // Test würde nichts prüfen (siehe Registry.Update). + idx, err := ws.Search.For(ctx, projekt.ID) + if err != nil { + t.Fatal(err) + } + + const runden, agenten, jeAgent = 20, 8, 10 + + for runde := 0; runde < runden; runde++ { + slug := fmt.Sprintf("umkaempft-%02d", runde) + + var wg sync.WaitGroup + var mu sync.Mutex + var fehler []error + + for a := 0; a < agenten; a++ { + wg.Add(1) + go func(a int) { + defer wg.Done() + melde := func(err error) { + mu.Lock() + fehler = append(fehler, err) + mu.Unlock() + } + + client, schliessen, err := verbinde(h, ws, projekt, fmt.Sprintf("agent-%d", a)) + if err != nil { + melde(err) + return + } + defer schliessen() + + for i := 0; i < jeAgent; i++ { + // Nur Buchstaben, damit der Tokenizer das Wort nicht zerlegt. + wort := fmt.Sprintf("marke%sx%s", buchstaben(runde*1000+a*100+i), "ende") + erg, err := client.CallTool(ctx, &mcp.CallToolParams{ + Name: "remember", + Arguments: map[string]any{ + "slug": slug, "title": "Umkämpft", "content": "Stand " + wort, + }, + }) + if err != nil { + melde(err) + return + } + if erg.IsError { + melde(fmt.Errorf("remember: %s", text(erg))) + return + } + } + }(a) + } + wg.Wait() + + if len(fehler) > 0 { + t.Fatalf("Runde %d: %d Fehler, erster: %v", runde, len(fehler), fehler[0]) + } + + e, err := ws.Store.EntryBySlug(ctx, projekt.ID, slug) + if err != nil { + t.Fatal(err) + } + if e.Version != agenten*jeAgent { + t.Errorf("Runde %d: Fassung %d, erwartet %d — ein Schreibvorgang ging verloren", + runde, e.Version, agenten*jeAgent) + } + + // Das Wort der letzten Fassung steht nach "Stand " im Inhalt. + letztesWort := e.Content[len("Stand "):] + gefunden := false + for _, r := range idx.Search(letztesWort, 5) { + if r.DocID == slug { + gefunden = true + } + } + if !gefunden { + t.Errorf("Runde %d: Datenbank hat Fassung %d mit %q, der Suchindex kennt das Wort nicht — "+ + "er hält einen älteren Stand", runde, e.Version, letztesWort) + } + } +} + +// buchstaben schreibt eine Zahl mit den Buchstaben a–j statt der Ziffern 0–9. +// So entsteht für jeden Schreibvorgang ein eindeutiges Wort ohne Ziffern. +func buchstaben(n int) string { + s := fmt.Sprintf("%06d", n) + b := []byte(s) + for i := range b { + b[i] = 'a' + (b[i] - '0') + } + return string(b) +} diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go index b872d7c..cdb1471 100644 --- a/internal/mcpserver/tools.go +++ b/internal/mcpserver/tools.go @@ -427,7 +427,7 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) { return nil, rememberOutput{}, err } - ws.Search.Update(project.ID, geschrieben) + ws.Search.Refresh(ctx, project.ID, geschrieben.Slug) sess, _ := web.SessionFromContext(ctx) ws.Store.LogAudit(ctx, store.AuditEvent{ @@ -505,7 +505,7 @@ func (h *Handler) addForget(server *mcp.Server, project store.Project) { return nil, forgetOutput{}, err } - ws.Search.Delete(project.ID, in.Slug) + ws.Search.Refresh(ctx, project.ID, in.Slug) sess, _ := web.SessionFromContext(ctx) ws.Store.LogAudit(ctx, store.AuditEvent{ diff --git a/internal/search/registry.go b/internal/search/registry.go index fd711c1..2c86d5c 100644 --- a/internal/search/registry.go +++ b/internal/search/registry.go @@ -2,7 +2,9 @@ package search import ( "context" + "errors" "fmt" + "log" "sync" "munin/internal/store" @@ -23,8 +25,32 @@ import ( type Registry struct { store *store.Store + // mu schützt nur die Zuordnung Projekt → Fach und wird immer nur kurz + // gehalten. Die eigentliche Arbeit (Aufbau, Aktualisierung) läuft unter + // der Sperre des jeweiligen Fachs. mu sync.Mutex - indexes map[string]*Index // Schlüssel: Projekt-ID + faecher map[string]*fach // Schlüssel: Projekt-ID + + // nachDemLesen wird nur von Tests gesetzt: Es läuft beim Aufbau zwischen + // dem Lesen der Einträge und dem Befüllen des Index. So lässt sich ein + // langsamer Aufbau nachstellen. + nachDemLesen func(projectID string) +} + +// fach hält den Index eines einzelnen Projekts. +// +// # Warum eine Sperre je Projekt? +// +// Früher gab es nur eine Sperre für alle Projekte des Workspace. Der erste +// Zugriff auf ein großes Projekt nach einem Neustart liest alle Einträge ein +// (etwa 170 ms je 10.000 Einträge) — und so lange mussten auch alle anderen +// Projekte desselben Workspace warten, selbst wenn ihr Index längst fertig war. +// +// Mit einer Sperre je Projekt wartet nur, wer auf genau dieses Projekt +// zugreift. Das muss er auch: Ohne fertigen Index gibt es nichts zu suchen. +type fach struct { + mu sync.Mutex + idx *Index // nil, solange der Index noch nicht (oder nicht mehr) aufgebaut ist } // NewRegistry erstellt eine leere Registry. Die Indizes entstehen beim ersten @@ -32,30 +58,60 @@ type Registry struct { func NewRegistry(s *store.Store) *Registry { return &Registry{ store: s, - indexes: make(map[string]*Index), + faecher: make(map[string]*fach), } } +// fachFuer gibt das Fach eines Projekts zurück und legt es bei Bedarf an — +// zunächst leer, aufgebaut wird erst in For. +func (r *Registry) fachFuer(projectID string) *fach { + r.mu.Lock() + defer r.mu.Unlock() + + f, ok := r.faecher[projectID] + if !ok { + f = &fach{} + r.faecher[projectID] = f + } + return f +} + +// vorhandenesFach gibt das Fach eines Projekts zurück, ohne eines anzulegen. +func (r *Registry) vorhandenesFach(projectID string) (*fach, bool) { + r.mu.Lock() + defer r.mu.Unlock() + + f, ok := r.faecher[projectID] + return f, ok +} + // For gibt den Index eines Projekts zurück und baut ihn beim ersten Zugriff auf. // // Der verzögerte Aufbau hat einen praktischen Grund: Beim Serverstart alle // Projekte einzulesen würde bei vielen Projekten den Start ausbremsen, obwohl // die meisten davon in dieser Laufzeit vielleicht nie angefragt werden. So zahlt // nur der erste Zugriff je Projekt. +// +// Greifen mehrere gleichzeitig auf ein Projekt ohne Index zu, baut ihn nur der +// erste auf. Die anderen warten an der Sperre des Fachs und bekommen danach +// den fertigen Index. func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) { - r.mu.Lock() - defer r.mu.Unlock() + f := r.fachFuer(projectID) - if idx, ok := r.indexes[projectID]; ok { - return idx, nil + f.mu.Lock() + defer f.mu.Unlock() + + if f.idx != nil { + return f.idx, nil } idx := NewIndex() if err := r.fill(ctx, idx, projectID); err != nil { + // Fach bleibt leer, der nächste Zugriff versucht es erneut. return nil, err } - r.indexes[projectID] = idx + f.idx = idx return idx, nil } @@ -66,31 +122,68 @@ func (r *Registry) fill(ctx context.Context, idx *Index, projectID string) error return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err) } + if r.nachDemLesen != nil { + r.nachDemLesen(projectID) + } + for _, e := range eintraege { idx.Index(e.Slug, e.Title, e.Content, e.Tags) } return nil } -// Update nimmt einen geänderten Eintrag in den Index auf. +// Refresh bringt einen Eintrag im Index auf den Stand der Datenbank — nach +// jedem Schreiben, Löschen oder Wiederherstellen aufrufen, nachdem die +// Änderung gespeichert ist. +// // Existiert für das Projekt noch kein Index, passiert nichts — er wird beim // ersten Zugriff ohnehin vollständig aufgebaut. -func (r *Registry) Update(projectID string, e store.Entry) { - r.mu.Lock() - defer r.mu.Unlock() - - if idx, ok := r.indexes[projectID]; ok { - idx.Index(e.Slug, e.Title, e.Content, e.Tags) +// +// # Warum aus der Datenbank lesen statt den geschriebenen Stand übernehmen? +// +// Speichern und Index aktualisieren sind zwei getrennte Schritte. Schreiben +// zwei Agenten gleichzeitig denselben Eintrag, können sie sich dazwischen +// überholen: Fassung 3 ist gespeichert, aber die Meldung für Fassung 2 kommt +// als letzte beim Index an. Früher übernahm der Index den mitgegebenen Stand +// und hielt dann bis zum Neustart Fassung 2. Genauso konnte ein gerade +// gelöschter Eintrag durch eine verspätete Meldung wieder auftauchen. +// +// Hier wird stattdessen unter der Sperre nachgelesen, was jetzt gespeichert +// ist. Der letzte Aufruf liest damit immer den neuesten Stand, egal in welcher +// Reihenfolge die Aufrufe ankommen. Das kostet eine kleine Leseabfrage je +// Schreibvorgang. +func (r *Registry) Refresh(ctx context.Context, projectID, slug string) { + f, ok := r.vorhandenesFach(projectID) + if !ok { + return } -} -// Delete entfernt einen Eintrag aus dem Index. -func (r *Registry) Delete(projectID, slug string) { - r.mu.Lock() - defer r.mu.Unlock() + // Läuft gerade der Aufbau dieses Projekts, wartet Refresh hier, bis er + // fertig ist. Das ist wichtig: Der Aufbau hat die Einträge womöglich vor + // dieser Änderung gelesen. Erst danach nachzulesen holt sie sicher nach. + f.mu.Lock() + defer f.mu.Unlock() - if idx, ok := r.indexes[projectID]; ok { - idx.Remove(slug) + if f.idx == nil { + return + } + + // WithoutCancel: Die Änderung ist schon gespeichert. Bricht der Client die + // Verbindung jetzt ab, muss der Index trotzdem nachziehen — sonst würde + // der Abbruch als Lesefehler gelten und den ganzen Index verwerfen. + e, err := r.store.EntryBySlug(context.WithoutCancel(ctx), projectID, slug) + switch { + case errors.Is(err, store.ErrNotFound): + // Gelöscht oder im Papierkorb: gehört nicht in die Suche. + f.idx.Remove(slug) + case err != nil: + // Der aktuelle Stand ist unbekannt. Statt zu raten, den ganzen Index + // des Projekts verwerfen — der nächste Zugriff baut ihn aus der + // Datenbank neu auf. + log.Printf("Suchindex: %q in Projekt %s nicht lesbar, Index wird neu aufgebaut: %v", slug, projectID, err) + f.idx = nil + default: + f.idx.Index(e.Slug, e.Title, e.Content, e.Tags) } } @@ -98,5 +191,5 @@ func (r *Registry) Delete(projectID, slug string) { func (r *Registry) Forget(projectID string) { r.mu.Lock() defer r.mu.Unlock() - delete(r.indexes, projectID) + delete(r.faecher, projectID) } diff --git a/internal/search/registry_test.go b/internal/search/registry_test.go new file mode 100644 index 0000000..52fd85a --- /dev/null +++ b/internal/search/registry_test.go @@ -0,0 +1,208 @@ +package search + +import ( + "context" + "path/filepath" + "testing" + "time" + + "munin/internal/store" +) + +// aufbauRegistry legt eine Datenbank mit einem Projekt und eine Registry mit +// bereits aufgebautem Index an. +func aufbauRegistry(t *testing.T) (*Registry, *store.Store, store.Project, *Index) { + t.Helper() + + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + ctx := context.Background() + projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") + if err != nil { + t.Fatal(err) + } + + r := NewRegistry(db) + // Den Index vorher anlegen — sonst sind Aktualisierungen ein No-op. + idx, err := r.For(ctx, projekt.ID) + if err != nil { + t.Fatal(err) + } + return r, db, projekt, idx +} + +func schreibe(t *testing.T, db *store.Store, projekt store.Project, slug, inhalt string) store.Entry { + t.Helper() + e, err := db.PutEntry(context.Background(), store.EntryWrite{ + ProjectID: projekt.ID, Slug: slug, Title: "Eintrag", Content: inhalt, Source: store.SourceAgent, + }) + if err != nil { + t.Fatal(err) + } + return e +} + +func findet(idx *Index, wort, slug string) bool { + for _, r := range idx.Search(wort, 10) { + if r.DocID == slug { + return true + } + } + return false +} + +// TestUeberholteAktualisierungHaeltIndexAktuell stellt den Fall nach, dass sich +// zwei Schreiber zwischen Datenbank und Index überholen: Fassung 2 und 3 sind +// gespeichert, aber die Meldung für Fassung 3 kommt beim Index zuerst an. +// +// Unter Last lässt sich das kaum zuverlässig auslösen (siehe +// TestGleichzeitigesSchreibenHaeltSuchindexAktuell im Paket mcpserver), hier +// wird die Reihenfolge deshalb von Hand vertauscht. +func TestUeberholteAktualisierungHaeltIndexAktuell(t *testing.T) { + r, db, projekt, idx := aufbauRegistry(t) + + schreibe(t, db, projekt, "umkaempft", "Stand wortzwei") + schreibe(t, db, projekt, "umkaempft", "Stand wortdrei") + + r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 3 + r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 2, verspätet + + if !findet(idx, "wortdrei", "umkaempft") { + t.Error("der Index kennt den aktuellen Stand (Fassung 3) nicht") + } + if findet(idx, "wortzwei", "umkaempft") { + t.Error("der Index hält einen überholten Stand (Fassung 2)") + } +} + +// TestVerspaeteteAktualisierungNachLoeschenBleibtWeg: Ein Eintrag wird +// geschrieben und gleich darauf gelöscht. Kommt die Meldung des Schreibers erst +// nach der des Löschens beim Index an, darf der Eintrag nicht wieder auftauchen. +func TestVerspaeteteAktualisierungNachLoeschenBleibtWeg(t *testing.T) { + r, db, projekt, idx := aufbauRegistry(t) + ctx := context.Background() + + schreibe(t, db, projekt, "kurzlebig", "Stand wortgeloescht") + if err := db.MoveEntryToTrash(ctx, projekt.ID, "kurzlebig", "test"); err != nil { + t.Fatal(err) + } + + r.Refresh(ctx, projekt.ID, "kurzlebig") // Löschender + r.Refresh(ctx, projekt.ID, "kurzlebig") // Schreiber, verspätet + + if findet(idx, "wortgeloescht", "kurzlebig") { + t.Error("ein gelöschter Eintrag ist wieder im Suchindex") + } +} + +// TestLangsamerAufbauBremstAndereProjekteNicht: Während der Index von Projekt A +// noch aufgebaut wird, muss Projekt B ohne Wartezeit durchkommen. Früher hielt +// der Aufbau eine Sperre für den ganzen Workspace. +func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) { + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + ctx := context.Background() + gross, err := db.CreateProject(ctx, store.NewID(), "gross", "Groß", "") + if err != nil { + t.Fatal(err) + } + klein, err := db.CreateProject(ctx, store.NewID(), "klein", "Klein", "") + if err != nil { + t.Fatal(err) + } + + r := NewRegistry(db) + imAufbau := make(chan struct{}) + weiter := make(chan struct{}) + r.nachDemLesen = func(projectID string) { + if projectID == gross.ID { + close(imAufbau) + <-weiter // Aufbau hängt, bis der Test ihn freigibt + } + } + + fertig := make(chan error) + go func() { + _, err := r.For(ctx, gross.ID) + fertig <- err + }() + <-imAufbau + + // Projekt B, während A noch aufgebaut wird. + klar := make(chan error) + go func() { + _, err := r.For(ctx, klein.ID) + klar <- err + }() + select { + case err := <-klar: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("Projekt B wartet auf den Aufbau von Projekt A") + } + + close(weiter) + if err := <-fertig; err != nil { + t.Fatal(err) + } +} + +// TestAenderungWaehrendDesAufbausGehtNichtVerloren: Der Aufbau hat die +// Einträge schon gelesen, da wird einer davon geändert. Das Refresh des +// Schreibers muss warten und danach den neuen Stand nachtragen — sonst hielte +// der fertige Index den Stand von vor der Änderung. +func TestAenderungWaehrendDesAufbausGehtNichtVerloren(t *testing.T) { + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + ctx := context.Background() + projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") + if err != nil { + t.Fatal(err) + } + schreibe(t, db, projekt, "eintrag", "Stand wortalt") + + r := NewRegistry(db) + refreshFertig := make(chan struct{}) + r.nachDemLesen = func(string) { + // Die Einträge sind gelesen, der Index noch nicht befüllt. Jetzt + // schreibt jemand und meldet es, wie es die Handler tun. + schreibe(t, db, projekt, "eintrag", "Stand wortneu") + go func() { + r.Refresh(ctx, projekt.ID, "eintrag") + close(refreshFertig) + }() + // Refresh darf nicht durchlaufen, solange der Aufbau läuft. + select { + case <-refreshFertig: + t.Error("Refresh lief während des Aufbaus durch") + case <-time.After(50 * time.Millisecond): + } + } + + idx, err := r.For(ctx, projekt.ID) + if err != nil { + t.Fatal(err) + } + <-refreshFertig + + if !findet(idx, "wortneu", "eintrag") { + t.Error("die Änderung während des Aufbaus fehlt im Index") + } + if findet(idx, "wortalt", "eintrag") { + t.Error("der Index hält den Stand von vor der Änderung") + } +} diff --git a/internal/store/audit.go b/internal/store/audit.go index ebdc0c5..18bf360 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -26,6 +26,12 @@ const ( // einmaliger Import die Nutzungsübersicht (4.6) nicht verzerrt. ActionEntryImport = "entry_import" + // ActionExportDownload: Ein Projekt oder ein ganzer Workspace wurde als + // ZIP heruntergeladen. Mit Projekt-ID beim Projekt, ohne beim Workspace. + // Ein Download verändert nichts, steht aber im Protokoll, weil er den + // gesamten Bestand auf einmal aus dem Haus trägt. + ActionExportDownload = "export_download" + ActionLoginSuccess = "login_success" ActionAuthFailure = "auth_failure" ActionProjectCreated = "project_created" @@ -33,6 +39,11 @@ const ( ActionProjectUpdated = "project_updated" ActionKeyCreated = "key_created" ActionKeyRevoked = "key_revoked" + + // Workspace-Admin-Zugänge (AUSBAUSTUFEN.md 4.8). Stehen im Protokoll des + // betroffenen Workspace, ohne Projekt — sichtbar unter /admin/audit. + ActionWorkspaceAdminCreated = "workspace_admin_created" + ActionWorkspaceAdminRevoked = "workspace_admin_revoked" ) // Feste Werte für das Feld Detail bei ActionEntryWrite. Feste Wörter statt diff --git a/internal/store/control_test.go b/internal/store/control_test.go index 423a362..5ecd141 100644 --- a/internal/store/control_test.go +++ b/internal/store/control_test.go @@ -98,3 +98,75 @@ func TestListWorkspaces(t *testing.T) { t.Errorf("unbekannte ID: err = %v, erwartet ErrNotFound", err) } } + +// TestWorkspaceAdmins deckt den Lebenslauf eines Workspace-Admin-Zugangs ab: +// anlegen, auflösen, zurückziehen — und dass ein anderer Workspace ihn nicht +// zurückziehen kann. +func TestWorkspaceAdmins(t *testing.T) { + c := testControl(t) + ctx := context.Background() + + wsA, err := c.CreateWorkspace(ctx, "A") + if err != nil { + t.Fatal(err) + } + wsB, err := c.CreateWorkspace(ctx, "B") + if err != nil { + t.Fatal(err) + } + + if _, err := c.CreateWorkspaceAdmin(ctx, wsA.ID, " ", "hash-leer"); err == nil { + t.Error("ein Zugang ohne Namen wurde angelegt") + } + + admin, err := c.CreateWorkspaceAdmin(ctx, wsA.ID, "Erika", "hash-1") + if err != nil { + t.Fatal(err) + } + + gefunden, err := c.ResolveWorkspaceAdminHash(ctx, "hash-1") + if err != nil || gefunden.ID != admin.ID || gefunden.WorkspaceID != wsA.ID { + t.Fatalf("ResolveWorkspaceAdminHash = %+v, %v", gefunden, err) + } + + if err := c.RevokeWorkspaceAdmin(ctx, wsB.ID, admin.ID); err != ErrNotFound { + t.Errorf("Zurückziehen aus fremdem Workspace: err = %v, erwartet ErrNotFound", err) + } + + if err := c.RevokeWorkspaceAdmin(ctx, wsA.ID, admin.ID); err != nil { + t.Fatal(err) + } + if _, err := c.ResolveWorkspaceAdminHash(ctx, "hash-1"); err != ErrNotFound { + t.Errorf("zurückgezogener Zugang löst noch auf: err = %v", err) + } + + // Die Zeile bleibt für die Verwaltung und das Protokoll erhalten. + gelesen, err := c.WorkspaceAdminByID(ctx, admin.ID) + if err != nil || !gelesen.Revoked() { + t.Errorf("WorkspaceAdminByID = %+v, %v — erwartet zurückgezogen", gelesen, err) + } +} + +// TestWorkspaceAdminsVanishWithWorkspace: Wird ein Workspace gelöscht, fallen +// seine Workspace-Admins mit weg (ON DELETE CASCADE) — sonst bliebe eine +// Sitzung auf einen nicht mehr vorhandenen Workspace übrig. +func TestWorkspaceAdminsVanishWithWorkspace(t *testing.T) { + c := testControl(t) + ctx := context.Background() + + ws, err := c.CreateWorkspace(ctx, "Weg") + if err != nil { + t.Fatal(err) + } + admin, err := c.CreateWorkspaceAdmin(ctx, ws.ID, "Erika", "hash-weg") + if err != nil { + t.Fatal(err) + } + + if err := c.DeleteWorkspace(ctx, ws.ID); err != nil { + t.Fatal(err) + } + if _, err := c.WorkspaceAdminByID(ctx, admin.ID); err != ErrNotFound { + t.Errorf("Workspace-Admin nach dem Löschen des Workspace: err = %v, erwartet ErrNotFound", err) + } +} diff --git a/internal/store/migrations_control/002_workspace_admins.sql b/internal/store/migrations_control/002_workspace_admins.sql new file mode 100644 index 0000000..36c2acb --- /dev/null +++ b/internal/store/migrations_control/002_workspace_admins.sql @@ -0,0 +1,27 @@ +-- Workspace-Admins (AUSBAUSTUFEN.md 4.8). +-- +-- Ein Workspace-Admin verwaltet in genau einem Workspace Projekte und deren +-- Keys — nur über die Weboberfläche, nie über MCP. Er ist damit das Gegenstück +-- zum Plattform-Admin (ADMIN_TOKEN), aber ohne Zugriff auf andere Workspaces, +-- das Schreibbudget oder den Export. +-- +-- Die Tabelle liegt in der Kontroll-Datenbank und nicht in der Workspace-Datei: +-- Beim Anmelden ist der Workspace noch unbekannt, und genau diese Frage — zu +-- welchem Workspace gehört dieses Token? — beantwortet die Kontroll-Datenbank. +-- +-- Wie bei den API-Keys steht hier nur der Hash, nie der Klartext. Ein +-- zurückgezogener Zugang bleibt als Zeile erhalten (revoked_at gesetzt), damit +-- das Protokoll weiterhin sagen kann, wer etwas getan hat. +-- +-- Leerer Text statt NULL für "noch nie benutzt" und "aktiv" — wie bei api_keys. +CREATE TABLE workspace_admins ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_used_at TEXT NOT NULL DEFAULT '', + revoked_at TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_workspace_admins_workspace ON workspace_admins (workspace_id); diff --git a/internal/store/models.go b/internal/store/models.go index 8549129..2d53c8a 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -83,12 +83,19 @@ const UnusedAfter = 90 * 24 * time.Hour // jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen // Zeitpunkt vorgeben können. func (k APIKey) Unused(jetzt time.Time) bool { - if k.Revoked() { + return ungenutzt(k.Revoked(), k.LastUsedAt, k.CreatedAt, jetzt) +} + +// ungenutzt ist die gemeinsame Regel hinter APIKey.Unused und +// WorkspaceAdmin.Unused — beide Zugangsarten sollen nach demselben Maßstab +// als „ungenutzt“ gelten. +func ungenutzt(zurueckgezogen bool, zuletztBenutzt, angelegt, jetzt time.Time) bool { + if zurueckgezogen { return false } - zuletzt := k.LastUsedAt + zuletzt := zuletztBenutzt if zuletzt.IsZero() { - zuletzt = k.CreatedAt + zuletzt = angelegt } return jetzt.Sub(zuletzt) > UnusedAfter } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 86b4a47..ee2773b 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -445,6 +445,32 @@ func TestAPIKeyUnused(t *testing.T) { } } +// TestWorkspaceAdminUnused: Workspace-Admins folgen derselben Regel wie Keys. +func TestWorkspaceAdminUnused(t *testing.T) { + jetzt := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) } + + faelle := []struct { + name string + admin WorkspaceAdmin + erwartet bool + }{ + {"gestern benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(1)}, false}, + {"vor 91 Tagen benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(91)}, true}, + {"nie benutzt, frisch angelegt", WorkspaceAdmin{CreatedAt: tage(3)}, false}, + {"nie benutzt, lange angelegt", WorkspaceAdmin{CreatedAt: tage(120)}, true}, + {"zurückgezogen", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false}, + } + + for _, f := range faelle { + t.Run(f.name, func(t *testing.T) { + if got := f.admin.Unused(jetzt); got != f.erwartet { + t.Errorf("Unused = %v, erwartet %v", got, f.erwartet) + } + }) + } +} + func TestCreateAPIKeyRequiresName(t *testing.T) { s := testStore(t) p := testProject(t, s) diff --git a/internal/store/workspace_admins.go b/internal/store/workspace_admins.go new file mode 100644 index 0000000..0cf084a --- /dev/null +++ b/internal/store/workspace_admins.go @@ -0,0 +1,191 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// WorkspaceAdmin ist ein Zugang, der in genau einem Workspace Projekte und +// deren Keys verwalten darf (AUSBAUSTUFEN.md 4.8). +// +// Er lebt in der Kontroll-Datenbank, nicht in der Workspace-Datei — siehe +// migrations_control/002_workspace_admins.sql, warum. +type WorkspaceAdmin struct { + ID string + WorkspaceID string + Name string + Hash string + CreatedAt time.Time + LastUsedAt time.Time // Nullzeitpunkt = noch nie benutzt + RevokedAt time.Time // Nullzeitpunkt = aktiv +} + +// Revoked sagt, ob der Zugang zurückgezogen wurde. +func (a WorkspaceAdmin) Revoked() bool { + return !a.RevokedAt.IsZero() +} + +// Unused sagt, ob ein aktiver Zugang seit mehr als UnusedAfter nicht benutzt +// wurde — dieselbe Regel wie bei APIKey.Unused. +// +// Bei einem Workspace-Admin wiegt das schwerer als bei einem Projekt-Key: Ein +// vergessenes Token öffnet nicht ein Projekt, sondern den ganzen Workspace. +func (a WorkspaceAdmin) Unused(jetzt time.Time) bool { + return ungenutzt(a.Revoked(), a.LastUsedAt, a.CreatedAt, jetzt) +} + +// workspaceAdminColumns steht in jeder Abfrage auf workspace_admins. +const workspaceAdminColumns = `id, workspace_id, name, hash, created_at, last_used_at, revoked_at` + +// scanWorkspaceAdmin liest eine Zeile aus workspace_admins. +func scanWorkspaceAdmin(scan func(...any) error) (WorkspaceAdmin, error) { + var a WorkspaceAdmin + var createdAt, lastUsedAt, revokedAt string + + if err := scan(&a.ID, &a.WorkspaceID, &a.Name, &a.Hash, + &createdAt, &lastUsedAt, &revokedAt); err != nil { + return WorkspaceAdmin{}, err + } + + a.CreatedAt = parseTime(createdAt) + a.LastUsedAt = parseTime(lastUsedAt) + a.RevokedAt = parseTime(revokedAt) + return a, nil +} + +// CreateWorkspaceAdmin legt einen Workspace-Admin-Zugang an. Übergeben wird der +// Hash, nie der Klartext — wie bei CreateAPIKey. +func (c *ControlStore) CreateWorkspaceAdmin(ctx context.Context, workspaceID, name, hash string) (WorkspaceAdmin, error) { + name = strings.TrimSpace(name) + if name == "" { + return WorkspaceAdmin{}, fmt.Errorf("der Zugang braucht einen Namen — daran erkennt man im Protokoll, wer etwas getan hat") + } + + a := WorkspaceAdmin{ + ID: NewID(), + WorkspaceID: workspaceID, + Name: name, + Hash: hash, + } + jetzt := NowString() + + if _, err := c.schreiben.ExecContext(ctx, + `INSERT INTO workspace_admins (id, workspace_id, name, hash, created_at) + VALUES (?, ?, ?, ?, ?)`, + a.ID, a.WorkspaceID, a.Name, a.Hash, jetzt, + ); err != nil { + return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht angelegt werden: %w", err) + } + + a.CreatedAt = parseTime(jetzt) + return a, nil +} + +// ResolveWorkspaceAdminHash schlägt einen aktiven Zugang anhand seines Hashes +// nach — der Weg beim Anmelden. +// +// Zurückgezogene Zugänge gelten als nicht vorhanden, wie bei ResolveKeyHash. +func (c *ControlStore) ResolveWorkspaceAdminHash(ctx context.Context, hash string) (WorkspaceAdmin, error) { + row := c.lesen.QueryRowContext(ctx, + `SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE hash = ? AND revoked_at = ''`, hash) + + a, err := scanWorkspaceAdmin(row.Scan) + if errors.Is(err, sql.ErrNoRows) { + return WorkspaceAdmin{}, ErrNotFound + } + if err != nil { + return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht aufgelöst werden: %w", err) + } + return a, nil +} + +// WorkspaceAdminByID liest einen Zugang anhand seiner ID — auch einen +// zurückgezogenen. Ob er noch gilt, entscheidet der Aufrufer über Revoked(). +func (c *ControlStore) WorkspaceAdminByID(ctx context.Context, id string) (WorkspaceAdmin, error) { + row := c.lesen.QueryRowContext(ctx, + `SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE id = ?`, id) + + a, err := scanWorkspaceAdmin(row.Scan) + if errors.Is(err, sql.ErrNoRows) { + return WorkspaceAdmin{}, ErrNotFound + } + if err != nil { + return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht gelesen werden: %w", err) + } + return a, nil +} + +// ListWorkspaceAdmins gibt alle Zugänge eines Workspace zurück, neueste zuerst. +// Zurückgezogene sind dabei, damit die Verwaltung sie noch anzeigen kann. +func (c *ControlStore) ListWorkspaceAdmins(ctx context.Context, workspaceID string) ([]WorkspaceAdmin, error) { + rows, err := c.lesen.QueryContext(ctx, + `SELECT `+workspaceAdminColumns+` FROM workspace_admins + WHERE workspace_id = ? ORDER BY created_at DESC`, workspaceID) + if err != nil { + return nil, fmt.Errorf("Workspace-Admins konnten nicht gelesen werden: %w", err) + } + defer rows.Close() + + var admins []WorkspaceAdmin + for rows.Next() { + a, err := scanWorkspaceAdmin(rows.Scan) + if err != nil { + return nil, err + } + admins = append(admins, a) + } + return admins, rows.Err() +} + +// ListActiveWorkspaceAdmins gibt die aktiven Zugänge ALLER Workspaces zurück — +// für die Übersicht des Plattform-Admins, in einer Abfrage statt einer je +// Workspace. +func (c *ControlStore) ListActiveWorkspaceAdmins(ctx context.Context) ([]WorkspaceAdmin, error) { + rows, err := c.lesen.QueryContext(ctx, + `SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE revoked_at = ''`) + if err != nil { + return nil, fmt.Errorf("Workspace-Admins konnten nicht gelesen werden: %w", err) + } + defer rows.Close() + + var admins []WorkspaceAdmin + for rows.Next() { + a, err := scanWorkspaceAdmin(rows.Scan) + if err != nil { + return nil, err + } + admins = append(admins, a) + } + return admins, rows.Err() +} + +// RevokeWorkspaceAdmin zieht einen Zugang zurück. +// +// Die Workspace-ID steht mit in der Bedingung: Eine untergeschobene ID aus +// einem anderen Workspace trifft dann keine Zeile und endet als ErrNotFound. +func (c *ControlStore) RevokeWorkspaceAdmin(ctx context.Context, workspaceID, id string) error { + res, err := c.schreiben.ExecContext(ctx, + `UPDATE workspace_admins SET revoked_at = ? + WHERE id = ? AND workspace_id = ? AND revoked_at = ''`, + NowString(), id, workspaceID, + ) + if err != nil { + return fmt.Errorf("Workspace-Admin konnte nicht zurückgezogen werden: %w", err) + } + return requireOneRow(res) +} + +// TouchWorkspaceAdmin vermerkt, dass ein Zugang gerade benutzt wurde — +// höchstens einmal pro Stunde, aus demselben Grund wie bei TouchAPIKey. +func (c *ControlStore) TouchWorkspaceAdmin(ctx context.Context, id string, lastUsed time.Time) error { + if time.Since(lastUsed) < time.Hour { + return nil + } + _, err := c.schreiben.ExecContext(ctx, + `UPDATE workspace_admins SET last_used_at = ? WHERE id = ?`, NowString(), id) + return err +} diff --git a/internal/web/context.go b/internal/web/context.go index ed63487..2283351 100644 --- a/internal/web/context.go +++ b/internal/web/context.go @@ -13,44 +13,101 @@ import ( // Session beschreibt, wer gerade zugreift. // -// Es gibt zwei Ausprägungen: +// Es gibt drei Ausprägungen: // // - Plattform-Admin (ADMIN_TOKEN): darf Projekte anlegen und löschen und auf // jedes Projekt eines gewählten Workspace zugreifen. Key und Project sind // leer. WorkspaceID ist leer, solange noch kein Workspace gewählt wurde — // eine solche Sitzung darf ausschließlich die Workspace-Auswahl aufrufen // (siehe requireWorkspace in middleware.go). +// - Workspace-Admin ("wa_…", AUSBAUSTUFEN.md 4.8): verwaltet Projekte und +// Keys in genau einem Workspace und darf dessen Projekte lesen. Er darf +// weder Einträge schreiben noch das Schreibbudget ändern, exportieren oder +// andere Workspaces sehen. Key und Project sind leer. // - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem // Projekt in genau einem Workspace gehört. Was er darf, sagt Role. +// +// # Warum ein eigenes Feld statt IsPlatformAdmin mitzubenutzen? +// +// Jede bestehende Prüfung auf IsPlatformAdmin weist den Workspace-Admin damit +// von selbst ab. Was er darf, ist unten an den Can…-Methoden einzeln +// freigeschaltet — was dort nicht steht, darf er nicht. Vergisst man bei einer +// künftigen Plattform-Funktion, an ihn zu denken, ist sie für ihn gesperrt, +// nicht offen. type Session struct { - Subject string // für die CSRF-Token-Berechnung - IsPlatformAdmin bool - WorkspaceID string - WorkspaceName string // nur beim Plattform-Admin gefüllt, für den Umschalter in der Kopfleiste - Key store.APIKey - Project store.Project - Role auth.Role + Subject string // für die CSRF-Token-Berechnung + IsPlatformAdmin bool + IsWorkspaceAdmin bool + WorkspaceID string + WorkspaceName string // bei Plattform- und Workspace-Admin gefüllt, für die Kopfleiste + WorkspaceAdmin store.WorkspaceAdmin + Key store.APIKey + Project store.Project + Role auth.Role } // CanAccessProject sagt, ob diese Sitzung auf ein Projekt zugreifen darf. // // Hier steckt die Mandantentrennung der Weboberfläche: Ein Projekt-Key kommt // ausschließlich an sein eigenes Projekt, ganz gleich welche URL aufgerufen wird. +// +// Die beiden Admins kommen an jedes Projekt — aber nur an die ihres +// Workspace: requireProject sucht das Projekt ausschließlich in der Datei des +// Workspace, an den die Sitzung gebunden ist. Ein Projekt eines anderen +// Workspace gibt es dort schlicht nicht. func (s Session) CanAccessProject(projectID string) bool { - if s.IsPlatformAdmin { + if s.IsPlatformAdmin || s.IsWorkspaceAdmin { return true } return s.Key.ProjectID == projectID } // CanWrite sagt, ob Einträge angelegt oder geändert werden dürfen. +// +// Der Workspace-Admin bewusst nicht: Einträge ändern die Teams über ihre +// Keys, damit im Protokoll eindeutig steht, welcher Zugang etwas geschrieben hat. func (s Session) CanWrite() bool { return s.IsPlatformAdmin || s.Role.CanWrite() } // CanManageKeys sagt, ob die Keys eines Projekts verwaltet werden dürfen. func (s Session) CanManageKeys() bool { - return s.IsPlatformAdmin || s.Role.CanManageKeys() + return s.IsPlatformAdmin || s.IsWorkspaceAdmin || s.Role.CanManageKeys() +} + +// CanImport sagt, ob ein ZIP-Archiv ins Projekt importiert werden darf. +// +// Ein Import schreibt Einträge, braucht also Schreibrecht — und legt viele +// auf einmal an, deshalb zusätzlich die Rolle der Key-Verwaltung. Für den +// Workspace-Admin fällt das damit von selbst weg (kein Schreibrecht). +func (s Session) CanImport() bool { + return s.CanWrite() && s.CanManageKeys() +} + +// CanDownloadProject sagt, ob ein ganzes Projekt als ZIP heruntergeladen +// werden darf. +// +// Vorerst nur mit der Rolle admin (dazu Plattform- und Workspace-Admin), +// entschieden am 2026-10-06. Fachlich wäre auch jeder mit Lesezugriff +// vertretbar — er kommt über die Oberfläche und MCP ohnehin an jeden Eintrag, +// das ZIP ist nur bequemer. Soll der Download breiter freigegeben werden, +// reicht es, diese eine Funktion zu ändern: Route, Seitenleiste und Tests +// richten sich danach. +func (s Session) CanDownloadProject() bool { + return s.CanManageKeys() +} + +// CanManageProjects sagt, ob Projekte des Workspace angelegt, bearbeitet und +// gelöscht werden dürfen. +func (s Session) CanManageProjects() bool { + return s.IsPlatformAdmin || s.IsWorkspaceAdmin +} + +// CanEditWriteBudget sagt, ob das Schreibbudget eines Projekts geändert werden +// darf. Nur der Plattform-Admin: Das Budget schützt den gemeinsamen Server, +// nicht den einzelnen Workspace (siehe AUSBAUSTUFEN.md 3.8 und 4.8). +func (s Session) CanEditWriteBudget() bool { + return s.IsPlatformAdmin } // ActorName ist der Name, unter dem diese Sitzung im Audit-Log auftaucht. @@ -58,6 +115,9 @@ func (s Session) ActorName() string { if s.IsPlatformAdmin { return "platform-admin" } + if s.IsWorkspaceAdmin { + return "workspace-admin:" + s.WorkspaceAdmin.Name + } return s.Key.Name } diff --git a/internal/web/download_test.go b/internal/web/download_test.go new file mode 100644 index 0000000..881106a --- /dev/null +++ b/internal/web/download_test.go @@ -0,0 +1,186 @@ +package web + +import ( + "archive/zip" + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "munin/internal/auth" + "munin/internal/store" +) + +// Tests für den Download als ZIP (handlers_download.go). + +// ladeHerunter ruft einen Download ab und gibt die Antwort zurück. +func ladeHerunter(h http.Handler, pfad string, cookie *http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, pfad, nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +// zipInhalt öffnet ein heruntergeladenes ZIP und gibt Pfad → Inhalt zurück. +func zipInhalt(t *testing.T, daten []byte) map[string]string { + t.Helper() + + archiv, err := zip.NewReader(bytes.NewReader(daten), int64(len(daten))) + if err != nil { + t.Fatalf("kein gültiges ZIP: %v", err) + } + inhalt := make(map[string]string) + for _, f := range archiv.File { + rc, err := f.Open() + if err != nil { + t.Fatal(err) + } + b, _ := io.ReadAll(rc) + rc.Close() + inhalt[f.Name] = string(b) + } + return inhalt +} + +func TestProjektDownloadAlsAdmin(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + if _, err := db.PutEntry(ctx, store.EntryWrite{ + ProjectID: projekt.ID, Slug: "deployment", Title: "Deployment", + Content: "Wir deployen freitags nie.", Source: store.SourceHuman, + }); err != nil { + t.Fatal(err) + } + + cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)) + w := ladeHerunter(h, "/p/testprojekt/export.zip", cookie) + + if w.Code != http.StatusOK { + t.Fatalf("Status %d, erwartet 200: %s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); ct != "application/zip" { + t.Errorf("Content-Type %q", ct) + } + if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, `filename="munin-testprojekt-`) { + t.Errorf("Content-Disposition %q", cd) + } + + inhalt := zipInhalt(t, w.Body.Bytes()) + if !strings.Contains(inhalt["testprojekt/deployment.md"], "freitags nie") { + t.Errorf("Eintrag fehlt im ZIP, enthalten: %v", schluessel(inhalt)) + } + if _, ok := inhalt["testprojekt/_index.md"]; !ok { + t.Error("_index.md fehlt im ZIP") + } + + // Der Download steht im Protokoll des Projekts. + ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID}) + gefunden := false + for _, e := range ereignisse { + if e.Action == store.ActionExportDownload && e.Actor == "verwalterin" { + gefunden = true + } + } + if !gefunden { + t.Error("Download fehlt im Protokoll") + } +} + +// TestProjektDownloadNurFuerAdmins: Lesen und Schreiben reichen (vorerst) +// nicht, siehe Session.CanDownloadProject. +func TestProjektDownloadNurFuerAdmins(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + for _, rolle := range []auth.Role{auth.RoleRead, auth.RoleWrite} { + cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "key-"+string(rolle), rolle)) + if w := ladeHerunter(h, "/p/testprojekt/export.zip", cookie); w.Code != http.StatusForbidden { + t.Errorf("Rolle %s: Status %d, erwartet 403", rolle, w.Code) + } + } +} + +// TestProjektDownloadFremdesProjekt: Der Admin von Projekt A kommt nicht an +// das ZIP von Projekt B. +func TestProjektDownloadFremdesProjekt(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + if _, err := db.CreateProject(context.Background(), projekt.WorkspaceID, "anderes", "Anderes", ""); err != nil { + t.Fatal(err) + } + cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)) + + if w := ladeHerunter(h, "/p/anderes/export.zip", cookie); w.Code == http.StatusOK { + t.Error("ZIP eines fremden Projekts ausgeliefert") + } +} + +func TestWorkspaceDownload(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + anderes, err := db.CreateProject(ctx, projekt.WorkspaceID, "anderes", "Anderes", "") + if err != nil { + t.Fatal(err) + } + for _, p := range []store.Project{projekt, anderes} { + if _, err := db.PutEntry(ctx, store.EntryWrite{ + ProjectID: p.ID, Slug: "notiz", Title: "Notiz", Content: "aus " + p.Slug, Source: store.SourceHuman, + }); err != nil { + t.Fatal(err) + } + } + + wsAdmin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + plattform := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + + for name, cookie := range map[string]*http.Cookie{"Workspace-Admin": wsAdmin.cookie, "Plattform-Admin": plattform} { + w := ladeHerunter(h, "/admin/export.zip", cookie) + if w.Code != http.StatusOK { + t.Fatalf("%s: Status %d, erwartet 200", name, w.Code) + } + inhalt := zipInhalt(t, w.Body.Bytes()) + for _, pfad := range []string{"_workspace.md", "testprojekt/notiz.md", "anderes/notiz.md"} { + if _, ok := inhalt[pfad]; !ok { + t.Errorf("%s: %s fehlt im ZIP, enthalten: %v", name, pfad, schluessel(inhalt)) + } + } + } + + // Ein Projekt-Admin verwaltet keine Projekte, also auch kein Workspace-ZIP. + key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)) + if w := ladeHerunter(h, "/admin/export.zip", key); w.Code != http.StatusForbidden { + t.Errorf("Projekt-Admin: Status %d, erwartet 403", w.Code) + } +} + +func TestDateinamenTeil(t *testing.T) { + faelle := map[string]string{ + "Plattform": "plattform", + "Büro Süd & Nord": "buero-sued-nord", + `Böse "Name"` + "\r\n": "boese-name", + "!!!": "workspace", + "": "workspace", + } + for eingabe, erwartet := range faelle { + if got := dateinamenTeil(eingabe, "workspace"); got != erwartet { + t.Errorf("dateinamenTeil(%q) = %q, erwartet %q", eingabe, got, erwartet) + } + } +} + +func schluessel(m map[string]string) []string { + var liste []string + for k := range m { + liste = append(liste, k) + } + return liste +} diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index 3a41f28..81611a1 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -44,7 +44,8 @@ type projectListItem struct { EigenesLimit int } -// handleProjectList zeigt alle Projekte — nur für den Plattform-Admin. +// handleProjectList zeigt alle Projekte des Workspace — für Plattform- und +// Workspace-Admin. func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) @@ -87,7 +88,7 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) { } ws.Store.LogAudit(r.Context(), store.AuditEvent{ - ProjectID: projekt.ID, Actor: "platform-admin", + ProjectID: projekt.ID, Actor: sess.ActorName(), Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), }) @@ -98,7 +99,7 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) { // handleProjectDelete löscht ein Projekt samt Einträgen und Keys. func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { - ws, _ := s.workspaceAndSession(r) + ws, sess := s.workspaceAndSession(r) id := r.FormValue("id") projekt, err := ws.Store.ProjectByID(r.Context(), id) @@ -127,7 +128,7 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { ws.Search.Forget(id) ws.Store.LogAudit(r.Context(), store.AuditEvent{ - ProjectID: id, Actor: "platform-admin", + ProjectID: id, Actor: sess.ActorName(), Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: projekt.Slug, }) @@ -214,8 +215,12 @@ func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) { } // handleProjectEditSave speichert Name, Beschreibung und Obergrenze. +// +// Die Obergrenze (Schreibbudget) darf nur der Plattform-Admin ändern. Beim +// Workspace-Admin wird das Feld nicht nur im Formular ausgeblendet, sondern +// hier ignoriert — ein von Hand nachgebautes Formular ändert sie also nicht. func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { - ws, _ := s.workspaceAndSession(r) + ws, sess := s.workspaceAndSession(r) projekt, ok := s.projectFromSlug(w, r) if !ok { @@ -230,12 +235,16 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { return } - roh := strings.TrimSpace(r.FormValue("write_limit_hourly")) - limit, err := strconv.Atoi(roh) - if err != nil || limit < 0 { - s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", - "Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).") - return + limit := projekt.WriteLimitHourly + if sess.CanEditWriteBudget() { + roh := strings.TrimSpace(r.FormValue("write_limit_hourly")) + var err error + limit, err = strconv.Atoi(roh) + if err != nil || limit < 0 { + s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", + "Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).") + return + } } aenderungen := projektAenderungen(projekt, name, beschreibung, limit) @@ -258,7 +267,7 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { // interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine // Betriebsentscheidung. ws.Store.LogAudit(r.Context(), store.AuditEvent{ - ProjectID: projekt.ID, Actor: "platform-admin", + ProjectID: projekt.ID, Actor: sess.ActorName(), Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: strings.Join(aenderungen, "; "), }) @@ -289,9 +298,10 @@ func projektAenderungen(alt store.Project, name, beschreibung string, limit int) // projectFromSlug löst den Bezeichner aus der URL zu einem Projekt auf. // // Eigene Funktion statt der Middleware requireProject: Die liegt unter -// /p/{project}/ und prüft den Zugriff eines Projekt-Zugangs. Hier ist ohnehin -// schon nur der Plattform-Admin unterwegs (siehe Routes), und der Bezeichner -// steht unter einem anderen Platzhalter. +// /p/{project}/ und prüft den Zugriff eines Projekt-Zugangs. Hier sind ohnehin +// nur Plattform- und Workspace-Admin unterwegs (siehe Routes), und der +// Bezeichner steht unter einem anderen Platzhalter. Gesucht wird nur in der +// Datei des eigenen Workspace. func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) { ws, sess := s.workspaceAndSession(r) @@ -482,7 +492,8 @@ func (s *Server) handleProjectAudit(w http.ResponseWriter, r *http.Request) { s.renderAudit(w, r, store.AuditFilter{ProjectID: project.ID}, false, "Protokoll – "+project.Name) } -// handleGlobalAudit zeigt das Protokoll aller Projekte — nur für den Plattform-Admin. +// handleGlobalAudit zeigt das Protokoll aller Projekte des Workspace — für +// Plattform- und Workspace-Admin. func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) { s.renderAudit(w, r, store.AuditFilter{}, true, "Protokoll") } diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index 0e38467..3d58d1b 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -15,6 +15,10 @@ import ( func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { sess, _ := SessionFrom(r) + if sess.IsWorkspaceAdmin { + http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) + return + } if sess.IsPlatformAdmin { if sess.WorkspaceID == "" { http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther) @@ -68,7 +72,7 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { // resolveLoginKey prüft einen Key beim Anmelden und gibt das Session-Subject zurück. // // Der Plattform-Admin bekommt hier noch keinen Workspace zugewiesen — das -// Subject ist zunächst nur "platform". Erst über /admin/workspaces wird ein +// Subject enthält zunächst nur den Fingerabdruck des ADMIN_TOKEN. Erst über /admin/workspaces wird ein // Workspace gewählt und das Subject um dessen ID ergänzt (siehe // handleWorkspaceSelect). func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) { @@ -81,7 +85,11 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) { // verglichen wird und die Antwortzeit nichts verrät. if auth.HashKey(key) == s.adminTokenHash { log.Printf("Anmeldung Plattform-Admin von %s", auth.ClientIP(r)) - return auth.SubjectPlatformAdmin, true + return s.plattformSubjekt(""), true + } + + if auth.LooksLikeWorkspaceAdminToken(key) { + return s.resolveWorkspaceAdminLogin(r, key) } if !auth.LooksLikeKey(key) { @@ -111,6 +119,31 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) { return auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID, true } +// resolveWorkspaceAdminLogin prüft das Token eines Workspace-Admins und gibt +// sein Session-Subject zurück. +// +// Die Anmeldung landet im Protokoll des Workspace, ohne Projekt — sie betrifft +// keines im Besonderen. Sichtbar ist sie im Protokoll aller Projekte +// (/admin/audit). +func (s *Server) resolveWorkspaceAdminLogin(r *http.Request, token string) (string, bool) { + admin, err := s.control.ResolveWorkspaceAdminHash(r.Context(), auth.HashKey(token)) + if err != nil { + return "", false + } + + ws, err := s.workspaces.Get(admin.WorkspaceID) + if err != nil { + return "", false + } + + ws.Store.LogAudit(r.Context(), store.AuditEvent{ + Actor: Session{IsWorkspaceAdmin: true, WorkspaceAdmin: admin}.ActorName(), + Action: store.ActionLoginSuccess, Source: store.SourceWeb, IP: auth.ClientIP(r), + }) + + return auth.SubjectWorkspaceAdminPrefix + admin.WorkspaceID + ":" + admin.ID, true +} + // handleLogout löscht den Session-Cookie. func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { cookie := &http.Cookie{ @@ -127,6 +160,12 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusSeeOther) } +// plattformSubjekt baut das Session-Subject des Plattform-Admins mit dem +// Fingerabdruck des aktuellen ADMIN_TOKEN. workspaceID darf leer sein. +func (s *Server) plattformSubjekt(workspaceID string) string { + return auth.PlatformSubject(s.adminFingerprint, workspaceID) +} + // setSessionCookie signiert das Subject und setzt es als Cookie. func (s *Server) setSessionCookie(w http.ResponseWriter, r *http.Request, subject string) { cookie := &http.Cookie{ diff --git a/internal/web/handlers_download.go b/internal/web/handlers_download.go new file mode 100644 index 0000000..907064b --- /dev/null +++ b/internal/web/handlers_download.go @@ -0,0 +1,125 @@ +package web + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + "munin/internal/auth" + "munin/internal/export" + "munin/internal/store" +) + +// ── Download als ZIP ───────────────────────────────────────────────────────── +// +// Der Export unter EXPORT_DIR landet auf der Platte des Servers — gut für +// Sicherungen und Git, aber für ein Team unerreichbar. Der Download erzeugt +// dasselbe als ZIP und schickt es direkt an den Browser: +// +// - unabhängig davon, ob EXPORT_DIR gesetzt ist +// - nichts wird auf dem Server gespeichert, jedes ZIP entsteht frisch +// - im selben Aufbau wie der Export, passt also direkt in den Import +// (/p/{projekt}/import) — auch auf einem anderen Server + +// handleProjectDownload liefert ein Projekt als ZIP aus. Wer das darf, +// entscheidet Session.CanDownloadProject (Route in server.go). +func (s *Server) handleProjectDownload(w http.ResponseWriter, r *http.Request) { + ws, sess := s.workspaceAndSession(r) + project, _ := ProjectFrom(r) + + dateien, err := export.ProjektDateien(r.Context(), ws.Store, project) + if err != nil { + s.serverError(w, "Export fehlgeschlagen", err) + return + } + + ws.Store.LogAudit(r.Context(), store.AuditEvent{ + ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), + Action: store.ActionExportDownload, Source: store.SourceWeb, IP: auth.ClientIP(r), + Detail: fmt.Sprintf("ZIP mit %d Einträgen", len(dateien)-1), // ohne _index.md + }) + + sendeZIP(w, "munin-"+project.Slug, dateien) +} + +// handleWorkspaceDownload liefert alle Projekte des gewählten Workspace als +// ein ZIP aus — für Plattform- und Workspace-Admins. +func (s *Server) handleWorkspaceDownload(w http.ResponseWriter, r *http.Request) { + ws, sess := s.workspaceAndSession(r) + + // Der Name kommt aus der Kontroll-Datenbank, nicht aus der Sitzung — wie + // beim Export auf die Platte (handleExport). + eintrag, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID) + if err != nil { + s.serverError(w, "Workspace konnte nicht gelesen werden", err) + return + } + + dateien, err := export.WorkspaceDateien(r.Context(), ws.Store, eintrag) + if err != nil { + s.serverError(w, "Export fehlgeschlagen", err) + return + } + + ws.Store.LogAudit(r.Context(), store.AuditEvent{ + Actor: sess.ActorName(), Action: store.ActionExportDownload, Source: store.SourceWeb, + IP: auth.ClientIP(r), Detail: fmt.Sprintf("ZIP des ganzen Workspace, %d Dateien", len(dateien)), + }) + + sendeZIP(w, "munin-"+dateinamenTeil(eintrag.Name, "workspace"), dateien) +} + +// sendeZIP schickt die Dateien als ZIP-Download. name ist der Dateiname ohne +// Datum und Endung. +// +// Bis hierher ist alles aus der Datenbank gelesen. Was jetzt noch scheitern +// kann, ist nur die Verbindung — dann bekommt der Browser ein abgebrochenes +// Archiv, und eine Fehlerseite wäre ohnehin nicht mehr möglich, weil die +// Kopfzeilen schon unterwegs sind. +func sendeZIP(w http.ResponseWriter, name string, dateien []export.Datei) { + dateiname := name + "-" + time.Now().Format("2006-01-02") + ".zip" + + w.Header().Set("Content-Type", "application/zip") + // attachment: Der Browser speichert die Datei, statt sie anzeigen zu wollen. + w.Header().Set("Content-Disposition", `attachment; filename="`+dateiname+`"`) + // Der Inhalt ist der gesamte Bestand. Er gehört in keinen Zwischenspeicher, + // weder im Browser noch in einem Proxy dazwischen. + w.Header().Set("Cache-Control", "no-store") + + if err := export.SchreibeZIP(w, dateien); err != nil { + // Nur ins Log: Antworten lässt sich nicht mehr. + log.Printf("ZIP-Download abgebrochen: %v", err) + } +} + +// dateinamenTeil macht aus einem frei gewählten Namen ein sicheres Stück +// Dateiname: Kleinbuchstaben, Ziffern und Bindestriche. Umlaute werden +// umschrieben. Bleibt nichts übrig, gilt ersatz. +// +// Wichtig ist das wegen der Kopfzeile Content-Disposition: Ein Anführungszeichen +// oder Zeilenumbruch im Workspace-Namen dürfte dort nicht hineingeraten. +func dateinamenTeil(name, ersatz string) string { + name = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss", + "Ä", "ae", "Ö", "oe", "Ü", "ue").Replace(strings.ToLower(name)) + + var b strings.Builder + bindestrich := false + for _, z := range name { + switch { + case z >= 'a' && z <= 'z', z >= '0' && z <= '9': + b.WriteRune(z) + bindestrich = false + case !bindestrich && b.Len() > 0: + b.WriteByte('-') + bindestrich = true + } + } + + ergebnis := strings.TrimSuffix(b.String(), "-") + if ergebnis == "" { + return ersatz + } + return ergebnis +} diff --git a/internal/web/handlers_entries.go b/internal/web/handlers_entries.go index 03961e7..74e70b6 100644 --- a/internal/web/handlers_entries.go +++ b/internal/web/handlers_entries.go @@ -353,7 +353,7 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) { return } - ws.Search.Update(project.ID, geschrieben) + ws.Search.Refresh(r.Context(), project.ID, geschrieben.Slug) ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb, @@ -398,7 +398,7 @@ func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) { return } - ws.Search.Delete(project.ID, slug) + ws.Search.Refresh(r.Context(), project.ID, slug) ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb, @@ -447,7 +447,7 @@ func (s *Server) handleEntryBulkTags(w http.ResponseWriter, r *http.Request) { } for _, e := range geaendert { - ws.Search.Update(project.ID, e) + ws.Search.Refresh(r.Context(), project.ID, e.Slug) } if len(geaendert) > 0 { @@ -610,7 +610,7 @@ func (s *Server) handleEntryRestore(w http.ResponseWriter, r *http.Request) { return } - ws.Search.Update(project.ID, geschrieben) + ws.Search.Refresh(r.Context(), project.ID, geschrieben.Slug) ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionEntryWrite, EntrySlug: e.Slug, Source: store.SourceWeb, diff --git a/internal/web/handlers_import.go b/internal/web/handlers_import.go index fc16bec..fc46a85 100644 --- a/internal/web/handlers_import.go +++ b/internal/web/handlers_import.go @@ -18,6 +18,10 @@ package web // Nur mit der Rolle admin, wie die Zugänge. Ein Import schreibt womöglich // tausende Einträge auf einmal, und wer ihn im Modus „Überschreiben“ startet, // ändert Einträge, die andere geschrieben haben. +// +// Der Workspace-Admin darf zwar Zugänge verwalten, aber keine Einträge +// schreiben — deshalb prüft die Route Session.CanImport und nicht allein die +// Key-Verwaltung. import ( "errors" @@ -100,7 +104,7 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { // Deshalb Index und Protokoll in jedem Fall nachziehen, erst danach den // Fehler melden. for _, e := range bericht.Geschrieben { - ws.Search.Update(project.ID, e) + ws.Search.Refresh(r.Context(), project.ID, e.Slug) } if !daten.Vorschau && len(bericht.Geschrieben) > 0 { // Ein Protokolleintrag für den ganzen Import statt einer je Datei. diff --git a/internal/web/handlers_papierkorb.go b/internal/web/handlers_papierkorb.go index 1588b5b..993fe46 100644 --- a/internal/web/handlers_papierkorb.go +++ b/internal/web/handlers_papierkorb.go @@ -122,7 +122,7 @@ func (s *Server) handleTrashRestore(w http.ResponseWriter, r *http.Request) { // eine eigene Protokollzeile — so lässt sich im Protokoll nach einem // einzelnen Bezeichner filtern und sein ganzer Weg nachvollziehen. for _, e := range zurueck { - ws.Search.Update(project.ID, e) + ws.Search.Refresh(r.Context(), project.ID, e.Slug) ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionEntryRestore, EntrySlug: e.Slug, Source: store.SourceWeb, diff --git a/internal/web/handlers_tags.go b/internal/web/handlers_tags.go new file mode 100644 index 0000000..e98c3e4 --- /dev/null +++ b/internal/web/handlers_tags.go @@ -0,0 +1,106 @@ +package web + +import ( + "net/http" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "munin/internal/store" +) + +// tagListData sind die Daten der Seite "Alle Schlagworte". +type tagListData struct { + // Sortierung ist "anzahl" (häufigste zuerst, Standard) oder "name". + Sortierung string + + // Tags ist die vollständige Liste — bei Sortierung "anzahl" genutzt. + Tags []store.TagCount + + // Gruppen fasst die Schlagworte nach Anfangsbuchstaben zusammen — bei + // Sortierung "name" genutzt. + Gruppen []tagGruppe +} + +// tagGruppe ist ein Buchstabenabschnitt der alphabetischen Ansicht. +type tagGruppe struct { + Buchstabe string + Tags []store.TagCount +} + +// handleTagList zeigt alle Schlagworte eines Projekts. Die Seitenleiste zeigt +// nur die häufigsten (siehe navTagLimit), hier steht der Rest. +func (s *Server) handleTagList(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + + tags, err := ws.Store.ListTags(r.Context(), project.ID) + if err != nil { + s.serverError(w, "Schlagworte konnten nicht geladen werden", err) + return + } + + daten := tagListData{Sortierung: "anzahl", Tags: tags} + if r.URL.Query().Get("sort") == "name" { + daten.Sortierung = "name" + daten.Gruppen = gruppiereTags(tags) + } + + s.render(w, r, "tags.html", http.StatusOK, pageData{ + Title: "Schlagworte – " + project.Name, + Bereich: "schlagworte", + Data: daten, + }) +} + +// gruppiereTags sortiert die Schlagworte alphabetisch und teilt sie nach +// ihrem Anfangsbuchstaben auf. Schlagworte, die nicht mit einem Buchstaben +// beginnen (etwa "2fa"), landen gemeinsam in der Gruppe "#". +func gruppiereTags(tags []store.TagCount) []tagGruppe { + // Eine Kopie sortieren, damit die Reihenfolge des Aufrufers erhalten bleibt. + sortiert := make([]store.TagCount, len(tags)) + copy(sortiert, tags) + sort.Slice(sortiert, func(i, j int) bool { + a, b := sortierSchluessel(sortiert[i].Tag), sortierSchluessel(sortiert[j].Tag) + if a != b { + return a < b + } + // Gleicher Schlüssel (etwa "ubersicht" und "übersicht"): die + // ursprüngliche Schreibweise entscheidet, damit die Reihenfolge + // bei jedem Aufruf dieselbe ist. + return sortiert[i].Tag < sortiert[j].Tag + }) + + var gruppen []tagGruppe + for _, t := range sortiert { + buchstabe := anfangsbuchstabe(t.Tag) + // Die Liste ist sortiert, also reicht der Blick auf die letzte Gruppe. + if len(gruppen) == 0 || gruppen[len(gruppen)-1].Buchstabe != buchstabe { + gruppen = append(gruppen, tagGruppe{Buchstabe: buchstabe}) + } + letzte := &gruppen[len(gruppen)-1] + letzte.Tags = append(letzte.Tags, t) + } + return gruppen +} + +// umlautErsatz bildet Umlaute auf ihren Grundbuchstaben ab — so sortiert +// "übersicht" bei "u" statt hinter "z" (ein einfacher Byte-Vergleich würde +// alle Umlaute ans Ende stellen). +var umlautErsatz = strings.NewReplacer("ä", "a", "ö", "o", "ü", "u", "ß", "ss") + +// sortierSchluessel liefert die Form eines Schlagworts, nach der sortiert wird. +func sortierSchluessel(tag string) string { + return umlautErsatz.Replace(strings.ToLower(tag)) +} + +// anfangsbuchstabe liefert die Gruppenüberschrift für ein Schlagwort: den +// ersten Buchstaben groß und ohne Umlaut, oder "#" für alles andere. +func anfangsbuchstabe(tag string) string { + erstes, _ := utf8.DecodeRuneInString(sortierSchluessel(tag)) + if !unicode.IsLetter(erstes) { + return "#" + } + return string(unicode.ToUpper(erstes)) +} diff --git a/internal/web/handlers_workspace_admins.go b/internal/web/handlers_workspace_admins.go new file mode 100644 index 0000000..4e05939 --- /dev/null +++ b/internal/web/handlers_workspace_admins.go @@ -0,0 +1,100 @@ +package web + +import ( + "errors" + "log" + "net/http" + "strings" + + "munin/internal/auth" + "munin/internal/store" +) + +// Workspace-Admins anlegen und zurückziehen (AUSBAUSTUFEN.md 4.8). +// +// Beides darf nur der Plattform-Admin (siehe Routes): Ein Workspace-Admin soll +// sich keine weiteren Workspace-Admins schaffen können — sonst ließe sich ein +// zurückgezogener Zugang durch einen vorher angelegten zweiten ersetzen, ohne +// dass der Plattform-Admin davon erfährt. +// +// Die Seite dazu ist die Bearbeitungsseite des Workspace +// (/admin/workspaces/{id}/edit), die Formulare senden an die Routen hier. + +// handleWorkspaceAdminCreate erzeugt einen Workspace-Admin-Zugang und zeigt +// das Token genau einmal an. +func (s *Server) handleWorkspaceAdminCreate(w http.ResponseWriter, r *http.Request) { + ws, ok := s.workspaceFromPath(w, r) + if !ok { + return + } + + name := strings.TrimSpace(r.FormValue("name")) + + klartext, err := auth.GenerateWorkspaceAdminToken() + if err != nil { + s.serverError(w, "Token konnte nicht erzeugt werden", err) + return + } + + admin, err := s.control.CreateWorkspaceAdmin(r.Context(), ws.ID, name, auth.HashKey(klartext)) + if err != nil { + s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, workspaceEditData{}, "", err.Error()) + return + } + + sess, _ := SessionFrom(r) + s.logWorkspaceAdminEvent(r, ws.ID, sess, store.ActionWorkspaceAdminCreated, admin.Name) + + s.renderWorkspaceEdit(w, r, http.StatusOK, ws, + workspaceEditData{NewToken: klartext, NewTokenName: admin.Name}, + "Workspace-Admin angelegt. Das Token wird nur jetzt angezeigt — bitte gleich übernehmen.", "") +} + +// handleWorkspaceAdminRevoke zieht einen Workspace-Admin-Zugang zurück. Eine +// laufende Sitzung endet damit beim nächsten Seitenaufruf (siehe +// workspaceAdminSession in middleware.go). +func (s *Server) handleWorkspaceAdminRevoke(w http.ResponseWriter, r *http.Request) { + ws, ok := s.workspaceFromPath(w, r) + if !ok { + return + } + + id := r.FormValue("id") + admin, err := s.control.WorkspaceAdminByID(r.Context(), id) + // Gehört der Zugang zu einem anderen Workspace, wie "nicht vorhanden" + // behandeln — dieselbe Regel wie bei den Keys (handleKeyRevoke). + if errors.Is(err, store.ErrNotFound) || (err == nil && admin.WorkspaceID != ws.ID) { + http.NotFound(w, r) + return + } + if err != nil { + s.serverError(w, "Workspace-Admin konnte nicht gelesen werden", err) + return + } + + if err := s.control.RevokeWorkspaceAdmin(r.Context(), ws.ID, id); err != nil && !errors.Is(err, store.ErrNotFound) { + s.serverError(w, "Workspace-Admin konnte nicht zurückgezogen werden", err) + return + } + + sess, _ := SessionFrom(r) + s.logWorkspaceAdminEvent(r, ws.ID, sess, store.ActionWorkspaceAdminRevoked, admin.Name) + + s.renderWorkspaceEdit(w, r, http.StatusOK, ws, workspaceEditData{}, + "Zugang zurückgezogen. Er gilt ab sofort nicht mehr.", "") +} + +// logWorkspaceAdminEvent schreibt ein Ereignis ins Protokoll des betroffenen +// Workspace — dort, wo auch alles steht, was der Zugang später tut. Der +// Workspace muss dafür nicht der gerade gewählte des Plattform-Admins sein. +func (s *Server) logWorkspaceAdminEvent(r *http.Request, workspaceID string, sess Session, aktion, name string) { + ws, err := s.workspaces.Get(workspaceID) + if err != nil { + log.Printf("Protokoll von Workspace %s nicht erreichbar: %v", workspaceID, err) + return + } + ws.Store.LogAudit(r.Context(), store.AuditEvent{ + Actor: sess.ActorName(), Action: aktion, Source: store.SourceWeb, + IP: auth.ClientIP(r), Detail: name, + }) +} diff --git a/internal/web/handlers_workspaces.go b/internal/web/handlers_workspaces.go index d732929..2fcfb7c 100644 --- a/internal/web/handlers_workspaces.go +++ b/internal/web/handlers_workspaces.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strings" + "time" "munin/internal/store" ) @@ -24,6 +25,11 @@ type workspaceItem struct { Name string CreatedAt string Projects int + + // Admins zählt die aktiven Workspace-Admins, AdminsUngenutzt die davon, + // die seit über 90 Tagen niemand benutzt hat (store.WorkspaceAdmin.Unused). + Admins int + AdminsUngenutzt int } // handleWorkspaceList zeigt alle Workspaces zur Auswahl. @@ -45,13 +51,32 @@ func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, sta return } + // Die aktiven Workspace-Admins aller Workspaces in einer Abfrage, danach + // je Workspace gezählt. + admins, err := s.control.ListActiveWorkspaceAdmins(r.Context()) + if err != nil { + s.serverError(w, "Workspace-Admins konnten nicht geladen werden", err) + return + } + jetzt := time.Now() + aktiv := make(map[string]int) + ungenutzt := make(map[string]int) + for _, a := range admins { + aktiv[a.WorkspaceID]++ + if a.Unused(jetzt) { + ungenutzt[a.WorkspaceID]++ + } + } + // Neueste zuerst — genau umgekehrt zu ListWorkspaces, das für // EnsureDefaultWorkspace die älteste zuerst braucht. items := make([]workspaceItem, len(workspaces)) for i, ws := range workspaces { items[len(workspaces)-1-i] = workspaceItem{ ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02"), - Projects: s.projectCount(r, ws.ID), + Projects: s.projectCount(r, ws.ID), + Admins: aktiv[ws.ID], + AdminsUngenutzt: ungenutzt[ws.ID], } } @@ -83,7 +108,7 @@ func (s *Server) handleWorkspaceCreate(w http.ResponseWriter, r *http.Request) { sess, _ := SessionFrom(r) log.Printf("Workspace %q (%s) angelegt von %s", ws.Name, ws.ID, sess.ActorName()) - s.setSessionCookie(w, r, "platform:"+ws.ID) + s.setSessionCookie(w, r, s.plattformSubjekt(ws.ID)) http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) } @@ -93,6 +118,20 @@ func (s *Server) handleWorkspaceCreate(w http.ResponseWriter, r *http.Request) { type workspaceEditData struct { Workspace workspaceItem Projects int + + // Admins sind die Workspace-Admin-Zugänge, auch zurückgezogene. + Admins []store.WorkspaceAdmin + + // Jetzt ist der Bezugszeitpunkt für WorkspaceAdmin.Unused im Template, + // Ungenutzt die Zahl der aktiven Zugänge, die seit über 90 Tagen niemand + // benutzt hat — wie in keyListData. + Jetzt time.Time + Ungenutzt int + + // NewToken wird nur unmittelbar nach dem Anlegen gesetzt und genau + // einmal angezeigt — wie NewKey in keyListData. + NewToken string + NewTokenName string } // handleWorkspaceEditForm zeigt das Formular zum Umbenennen und die Gefahrenzone. @@ -101,7 +140,7 @@ func (s *Server) handleWorkspaceEditForm(w http.ResponseWriter, r *http.Request) if !ok { return } - s.renderWorkspaceEdit(w, r, http.StatusOK, ws, "", "") + s.renderWorkspaceEdit(w, r, http.StatusOK, ws, workspaceEditData{}, "", "") } // handleWorkspaceEditSave benennt einen Workspace um. @@ -113,7 +152,7 @@ func (s *Server) handleWorkspaceEditSave(w http.ResponseWriter, r *http.Request) name := strings.TrimSpace(r.FormValue("name")) if name == "" { - s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, "", "Der Name darf nicht leer sein.") + s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, workspaceEditData{}, "", "Der Name darf nicht leer sein.") return } @@ -142,7 +181,7 @@ func (s *Server) handleWorkspaceDelete(w http.ResponseWriter, r *http.Request) { } if r.FormValue("confirm") != ws.Name { - s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, "", + s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, workspaceEditData{}, "", "Zum Löschen muss der Workspace-Name \""+ws.Name+"\" zur Bestätigung eingegeben werden.") return } @@ -164,7 +203,7 @@ func (s *Server) handleWorkspaceDelete(w http.ResponseWriter, r *http.Request) { log.Printf("Workspace %q (%s) gelöscht von %s", ws.Name, ws.ID, sess.ActorName()) if sess.WorkspaceID == ws.ID { - s.setSessionCookie(w, r, "platform") + s.setSessionCookie(w, r, s.plattformSubjekt("")) } http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape("Workspace gelöscht"), http.StatusSeeOther) } @@ -190,16 +229,34 @@ func (s *Server) projectCount(r *http.Request, workspaceID string) int { return len(projekte) } -// renderWorkspaceEdit lädt die Projektzahl und rendert die Bearbeitungsseite. +// renderWorkspaceEdit lädt Projektzahl und Workspace-Admins und rendert die +// Bearbeitungsseite. In daten sind nur NewToken/NewTokenName von Belang, den +// Rest füllt diese Funktion. func (s *Server) renderWorkspaceEdit(w http.ResponseWriter, r *http.Request, status int, - ws workspaceItem, flash, fehler string) { + ws workspaceItem, daten workspaceEditData, flash, fehler string) { + + admins, err := s.control.ListWorkspaceAdmins(r.Context(), ws.ID) + if err != nil { + s.serverError(w, "Workspace-Admins konnten nicht geladen werden", err) + return + } + + daten.Workspace = ws + daten.Projects = s.projectCount(r, ws.ID) + daten.Admins = admins + daten.Jetzt = time.Now() + for _, a := range admins { + if a.Unused(daten.Jetzt) { + daten.Ungenutzt++ + } + } s.render(w, r, "workspace-edit.html", status, pageData{ Title: ws.Name, Bereich: "workspaces", Flash: flash, Error: fehler, - Data: workspaceEditData{Workspace: ws, Projects: s.projectCount(r, ws.ID)}, + Data: daten, }) } @@ -238,6 +295,6 @@ func (s *Server) handleWorkspaceSelect(w http.ResponseWriter, r *http.Request) { return } - s.setSessionCookie(w, r, "platform:"+id) + s.setSessionCookie(w, r, s.plattformSubjekt(id)) http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) } diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 1a2c804..14cca20 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -1,6 +1,7 @@ package web import ( + "crypto/subtle" "errors" "fmt" "log" @@ -69,39 +70,58 @@ func (s *Server) requireSession(next http.Handler) http.Handler { // sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung. // -// Ein Subject hat eine von drei Formen: +// Ein Subject hat eine von vier Formen: // -// - "platform" — Plattform-Admin, noch kein Workspace gewählt. -// - "platform:" — Plattform-Admin, mit gewähltem Workspace. +// - "platform/" — Plattform-Admin, noch kein Workspace +// gewählt. Der Fingerabdruck muss zum aktuellen ADMIN_TOKEN passen +// (siehe auth.PlatformSubject). +// - "platform/:" — Plattform-Admin, mit +// gewähltem Workspace. +// - "wsadmin::" — Workspace-Admin, fest an seinen +// Workspace gebunden (AUSBAUSTUFEN.md 4.8). // - "key::" — Projekt-Zugang, immer an genau einen // Workspace gebunden (den seines Keys). // // Die Workspace-ID steht direkt im (signierten, also fälschungssicheren) // Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank // der richtigen Workspace-Datei zuordnen. +// +// Sitzungen aus der Zeit vor dem Fingerabdruck ("platform" ohne Zusatz) passen +// zu keiner Form mehr und enden. Der Plattform-Admin meldet sich einmal neu an. func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) { - if subject == auth.SubjectPlatformAdmin { - return Session{ - Subject: subject, - IsPlatformAdmin: true, - Role: auth.RoleAdmin, - }, true - } + if fingerabdruck, workspaceID, ok := auth.ParsePlatformSubject(subject); ok { + // Mit einem anderen ADMIN_TOKEN entstanden: Das Token wurde seitdem + // ersetzt, die Sitzung gilt nicht mehr. Vergleich in konstanter Zeit, + // damit die Antwortzeit nichts über den richtigen Abdruck verrät. + if subtle.ConstantTimeCompare([]byte(fingerabdruck), []byte(s.adminFingerprint)) != 1 { + return Session{}, false + } - if rest, ok := strings.CutPrefix(subject, auth.SubjectPlatformAdmin+":"); ok { - ws, err := s.control.WorkspaceByID(r.Context(), rest) + if workspaceID == "" { + return Session{ + Subject: subject, + IsPlatformAdmin: true, + Role: auth.RoleAdmin, + }, true + } + + ws, err := s.control.WorkspaceByID(r.Context(), workspaceID) if err != nil { return Session{}, false } return Session{ Subject: subject, IsPlatformAdmin: true, - WorkspaceID: rest, + WorkspaceID: workspaceID, WorkspaceName: ws.Name, Role: auth.RoleAdmin, }, true } + if rest, ok := strings.CutPrefix(subject, auth.SubjectWorkspaceAdminPrefix); ok { + return s.workspaceAdminSession(r, subject, rest) + } + rest, ok := auth.KeyIDFromSubject(subject) if !ok { return Session{}, false @@ -135,6 +155,45 @@ func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, b }, true } +// workspaceAdminSession baut die Sitzung eines Workspace-Admins aus dem Rest +// seines Subjects (":"). +// +// Wie bei den Keys wird der Zugang bei jedem Request frisch nachgesehen: Ein +// zurückgezogener Zugang beendet die Sitzung sofort, ebenso ein gelöschter +// Workspace (dessen Zugänge fallen per ON DELETE CASCADE mit weg). +// +// Der Vergleich der Workspace-IDs ist eine zweite Sicherung: Das Subject ist +// signiert und damit nicht fälschbar, aber die Bindung an den Workspace soll +// nicht allein davon abhängen, dass es beim Anmelden richtig gebildet wurde. +func (s *Server) workspaceAdminSession(r *http.Request, subject, rest string) (Session, bool) { + workspaceID, adminID, ok := strings.Cut(rest, ":") + if !ok { + return Session{}, false + } + + admin, err := s.control.WorkspaceAdminByID(r.Context(), adminID) + if err != nil || admin.Revoked() || admin.WorkspaceID != workspaceID { + return Session{}, false + } + + ws, err := s.control.WorkspaceByID(r.Context(), workspaceID) + if err != nil { + return Session{}, false + } + + // Nebenbei die letzte Nutzung vermerken — eine Sitzung gilt 30 Tage, der + // Login allein sagt also wenig darüber, ob der Zugang noch gebraucht wird. + _ = s.control.TouchWorkspaceAdmin(r.Context(), admin.ID, admin.LastUsedAt) + + return Session{ + Subject: subject, + IsWorkspaceAdmin: true, + WorkspaceID: workspaceID, + WorkspaceName: ws.Name, + WorkspaceAdmin: admin, + }, true +} + // checkCSRF prüft bei schreibenden Requests das CSRF-Token. // // GET, HEAD und OPTIONS gelten als sichere Methoden — sie sollen nichts @@ -212,8 +271,9 @@ func (s *Server) requireProject(next http.Handler) http.Handler { // requireWorkspace lässt nur Sitzungen mit gewähltem Workspace durch. // -// Betrifft nur den Plattform-Admin: Eine Projekt-Sitzung hat ihren Workspace -// immer schon über ihren Key (siehe sessionFromSubject). Der Plattform-Admin +// Betrifft nur den Plattform-Admin: Projekt- und Workspace-Admin-Sitzungen +// haben ihren Workspace immer schon im Subject (siehe sessionFromSubject). Der +// Plattform-Admin // dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er // verwalten will — dafür ist /admin/workspaces da. func (s *Server) requireWorkspace(next http.Handler) http.Handler { @@ -250,6 +310,32 @@ func (s *Server) requireWrite(next http.Handler) http.Handler { }) } +// requireProjectManagement lässt nur Sitzungen durch, die Projekte verwalten +// dürfen: Plattform-Admin und Workspace-Admin. +func (s *Server) requireProjectManagement(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sess, ok := SessionFrom(r) + if !ok || !sess.CanManageProjects() { + http.Error(w, "Nur für Plattform- und Workspace-Admins verfügbar", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// requireImport lässt nur Sitzungen durch, die importieren dürfen (siehe +// Session.CanImport). +func (s *Server) requireImport(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sess, ok := SessionFrom(r) + if !ok || !sess.CanImport() { + http.Error(w, "Für den Import wird die Rolle \"admin\" benötigt.", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + // requireKeyManagement lässt nur Sitzungen durch, die Keys verwalten dürfen. func (s *Server) requireKeyManagement(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -262,6 +348,19 @@ func (s *Server) requireKeyManagement(next http.Handler) http.Handler { }) } +// requireProjectDownload lässt nur Sitzungen durch, die ein Projekt als ZIP +// herunterladen dürfen (siehe Session.CanDownloadProject). +func (s *Server) requireProjectDownload(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sess, ok := SessionFrom(r) + if !ok || !sess.CanDownloadProject() { + http.Error(w, "Für den Download des ganzen Projekts wird die Rolle \"admin\" benötigt.", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + // ── Allgemeine Schutzschichten ─────────────────────────────────────────────── // SecurityHeaders setzt bei jeder Antwort die Schutz-Header des Browsers. diff --git a/internal/web/server.go b/internal/web/server.go index 691f95f..47b2bc8 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -39,6 +39,11 @@ type Server struct { // nicht der Klartext — dann liegt das Geheimnis nicht dauerhaft im Speicher // herum und der Vergleich läuft über dieselbe Funktion wie bei den API-Keys. adminTokenHash string + + // adminFingerprint steckt in jeder Sitzung des Plattform-Admins und wird + // bei jedem Request verglichen. Ändert sich das ADMIN_TOKEN, passt er + // nicht mehr, und alle Admin-Sitzungen enden (siehe auth.PlatformSubject). + adminFingerprint string } // Config sammelt die Angaben zum Bauen eines Servers. @@ -63,6 +68,8 @@ func NewServer(cfg Config) (*Server, error) { return nil, err } + adminTokenHash := auth.HashKey(cfg.AdminToken) + return &Server{ workspaces: cfg.Workspaces, control: cfg.Control, @@ -72,7 +79,9 @@ func NewServer(cfg Config) (*Server, error) { templates: tmpl, exporter: cfg.Exporter, writeBudget: cfg.WriteBudget, - adminTokenHash: auth.HashKey(cfg.AdminToken), + adminTokenHash: adminTokenHash, + + adminFingerprint: cfg.Signer.Fingerprint(adminTokenHash), }, nil } @@ -126,23 +135,37 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { // /admin/workspaces, sonst wäre er mit "create"/"select"/"delete" mehrdeutig. mux.Handle("GET /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm))) mux.Handle("POST /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave))) + // Workspace-Admins eines Workspace anlegen und zurückziehen — nur der + // Plattform-Admin, siehe handlers_workspace_admins.go. + mux.Handle("POST /admin/workspaces/{id}/admins/create", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceAdminCreate))) + mux.Handle("POST /admin/workspaces/{id}/admins/revoke", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceAdminRevoke))) - // Plattform-Verwaltung. requireWorkspace stellt sicher, dass der + // Verwaltung eines Workspace. requireWorkspace stellt sicher, dass der // Plattform-Admin einen Workspace gewählt hat, bevor er dessen Projekte // verwaltet — siehe requireWorkspace in middleware.go. + // + // Zwei Stufen: Projekte und das Protokoll aller Projekte darf auch der + // Workspace-Admin (AUSBAUSTUFEN.md 4.8), alles Übrige bleibt beim + // Plattform-Admin. Neue Routen gehören im Zweifel nach imWorkspace. imWorkspace := func(h http.HandlerFunc) http.Handler { return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h))) } - mux.Handle("GET /admin/projects", imWorkspace(s.handleProjectList)) - mux.Handle("POST /admin/projects/create", imWorkspace(s.handleProjectCreate)) - mux.Handle("POST /admin/projects/delete", imWorkspace(s.handleProjectDelete)) + projektVerwaltung := func(h http.HandlerFunc) http.Handler { + return s.requireSession(s.requireWorkspace(s.requireProjectManagement(h))) + } + mux.Handle("GET /admin/projects", projektVerwaltung(s.handleProjectList)) + mux.Handle("POST /admin/projects/create", projektVerwaltung(s.handleProjectCreate)) + mux.Handle("POST /admin/projects/delete", projektVerwaltung(s.handleProjectDelete)) // Die Bearbeitung liegt unter {slug}/edit statt direkt unter {slug}: Sonst // wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen // Projekt-Bezeichner. - mux.Handle("GET /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditForm)) - mux.Handle("POST /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditSave)) - mux.Handle("GET /admin/audit", imWorkspace(s.handleGlobalAudit)) + mux.Handle("GET /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditForm)) + mux.Handle("POST /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditSave)) + mux.Handle("GET /admin/audit", projektVerwaltung(s.handleGlobalAudit)) mux.Handle("POST /admin/export", imWorkspace(s.handleExport)) + // Download als ZIP: unabhängig von EXPORT_DIR, für alle, die Projekte + // verwalten — also auch den Workspace-Admin (siehe handlers_download.go). + mux.Handle("GET /admin/export.zip", projektVerwaltung(s.handleWorkspaceDownload)) // Projektseiten. mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList)) @@ -155,6 +178,7 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { mux.Handle("POST /p/{project}/e/{slug}/versions/{version}/restore", mitSchreibrecht(s.handleEntryRestore)) mux.Handle("GET /p/{project}/graph", imProjekt(s.handleEntryGraph)) mux.Handle("GET /p/{project}/graph/data", imProjekt(s.handleEntryGraphData)) + mux.Handle("GET /p/{project}/tags", imProjekt(s.handleTagList)) mux.Handle("GET /p/{project}/e/{slug}/edit", mitSchreibrecht(s.handleEntryEdit)) mux.Handle("POST /p/{project}/e/{slug}/save", mitSchreibrecht(s.handleEntrySave)) mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete)) @@ -168,12 +192,14 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { mux.Handle("GET /p/{project}/usage", imProjekt(s.handleProjectUsage)) // Key-Verwaltung. + mux.Handle("GET /p/{project}/export.zip", s.requireSession(s.requireProject(s.requireProjectDownload(http.HandlerFunc(s.handleProjectDownload))))) mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList))))) - // Import: dieselbe Rolle wie die Key-Verwaltung, siehe handlers_import.go. - // Die größere Body-Grenze für POST setzt LimitBodies (middleware.go). - mux.Handle("GET /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImportForm))))) - mux.Handle("POST /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImport))))) + // Import: Rolle admin mit Schreibrecht, siehe Session.CanImport und + // handlers_import.go. Die größere Body-Grenze für POST setzt LimitBodies + // (middleware.go). + mux.Handle("GET /p/{project}/import", s.requireSession(s.requireProject(s.requireImport(http.HandlerFunc(s.handleImportForm))))) + mux.Handle("POST /p/{project}/import", s.requireSession(s.requireProject(s.requireImport(http.HandlerFunc(s.handleImport))))) mux.Handle("POST /p/{project}/keys/create", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyCreate))))) mux.Handle("POST /p/{project}/keys/revoke", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyRevoke))))) } @@ -202,7 +228,8 @@ type pageData struct { ExportEnabled bool // Bereich markiert den aktiven Punkt in der Seitenleiste: - // "eintraege", "zugaenge", "protokoll", "projekte" oder "hilfe". + // etwa "eintraege", "graph", "schlagworte", "zugaenge", "protokoll", + // "projekte" oder "hilfe". Bereich string // Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit @@ -217,14 +244,27 @@ type pageData struct { // navData sind die Inhalte der Seitenleiste. type navData struct { - // Projects ist nur beim Plattform-Admin gefüllt — ein Projekt-Key hat - // ohnehin nur Zugriff auf sein eigenes Projekt. + // Projects ist nur bei Plattform- und Workspace-Admin gefüllt — ein + // Projekt-Key hat ohnehin nur Zugriff auf sein eigenes Projekt. Projects []store.Project - // Tags sind die Schlagworte des aktuellen Projekts, häufigste zuerst. + // Tags sind die häufigsten Schlagworte des aktuellen Projekts (höchstens + // navTagLimit Stück), häufigste zuerst. Ist gerade ein Schlagwort gewählt, + // das nicht dazugehört, steht es zusätzlich am Ende — sonst wäre der + // aktive Filter in der Seitenleiste unsichtbar. Tags []store.TagCount + + // TagsGesamt ist die Zahl aller Schlagworte des Projekts. Ist sie größer + // als len(Tags), zeigt die Seitenleiste einen Link auf die vollständige + // Liste (/p/{project}/tags). + TagsGesamt int } +// navTagLimit begrenzt die Schlagworte in der Seitenleiste. Ohne Grenze wird +// die Leiste in großen Projekten so lang, dass die übrigen Punkte darin +// untergehen. Alle Schlagworte stehen auf einer eigenen Seite. +const navTagLimit = 10 + // render führt ein Template aus und schickt das Ergebnis an den Browser. // // Gerendert wird zuerst in einen Puffer im Arbeitsspeicher. Erst wenn das @@ -240,7 +280,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, sta data.Project = project } data.ExportEnabled = s.exporter != nil - data.Nav = s.buildNav(r, data.Session, data.Project) + data.Nav = s.buildNav(r, data.Session, data.Project, data.AktivesTag) var puffer bytes.Buffer if err := s.templates.ExecuteTemplate(&puffer, name, data); err != nil { @@ -265,7 +305,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, sta // // Fehler brechen die Seite nicht ab: Eine unvollständige Navigation ist // ärgerlich, aber kein Grund, den eigentlichen Inhalt nicht anzuzeigen. -func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) navData { +func (s *Server) buildNav(r *http.Request, sess Session, project store.Project, aktivesTag string) navData { var nav navData // Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl) gibt @@ -275,7 +315,7 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) return nav } - if sess.IsPlatformAdmin { + if sess.CanManageProjects() { projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) if err != nil { log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err) @@ -289,13 +329,41 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) if err != nil { log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err) } else { - nav.Tags = tags + nav.Tags = kuerzeTags(tags, navTagLimit, aktivesTag) + nav.TagsGesamt = len(tags) } } return nav } +// kuerzeTags nimmt die ersten limit Schlagworte aus tags (die schon nach +// Häufigkeit sortiert sind). Gehört aktiv nicht dazu, wird es hinten +// angehängt, damit der gewählte Filter sichtbar bleibt. +func kuerzeTags(tags []store.TagCount, limit int, aktiv string) []store.TagCount { + if len(tags) <= limit { + return tags + } + // Eine Kopie statt tags[:limit]: append auf ein Teilstück würde sonst + // das darunterliegende Array von tags überschreiben. + kurz := make([]store.TagCount, limit, limit+1) + copy(kurz, tags[:limit]) + if aktiv == "" { + return kurz + } + for _, t := range kurz { + if t.Tag == aktiv { + return kurz + } + } + for _, t := range tags[limit:] { + if t.Tag == aktiv { + return append(kurz, t) + } + } + return kurz +} + // workspaceAndSession liest Workspace und Sitzung der aktuellen Anfrage. // // Nur für Handler gedacht, die über requireWorkspace oder requireProject diff --git a/internal/web/tags_test.go b/internal/web/tags_test.go new file mode 100644 index 0000000..f236911 --- /dev/null +++ b/internal/web/tags_test.go @@ -0,0 +1,161 @@ +package web + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "munin/internal/auth" + "munin/internal/store" +) + +// holeSeite ruft eine Seite mit Lesezugang ab und gibt den HTML-Text zurück. +func holeSeite(t *testing.T, h http.Handler, cookie *http.Cookie, pfad string) string { + t.Helper() + r := httptest.NewRequest(http.MethodGet, pfad, nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("%s: Status = %d, erwartet 200", pfad, w.Code) + } + return w.Body.String() +} + +// legeSchlagworteAn erzeugt anzahl Einträge mit je einem eigenen Schlagwort +// "tag-00", "tag-01", … — tag-00 kommt dabei am häufigsten vor, damit die +// Rangfolge nach Häufigkeit eindeutig ist. +func legeSchlagworteAn(t *testing.T, db *store.Store, projectID string, anzahl int) { + t.Helper() + ctx := context.Background() + for i := 0; i < anzahl; i++ { + tags := []string{fmt.Sprintf("tag-%02d", i)} + if i > 0 { + tags = append(tags, "tag-00") + } + _, err := db.PutEntry(ctx, store.EntryWrite{ProjectID: projectID, Slug: fmt.Sprintf("e-%02d", i), + Title: "E", Content: "x", Tags: tags, Source: store.SourceHuman}) + if err != nil { + t.Fatal(err) + } + } +} + +// TestNavZeigtNurHaeufigsteSchlagworte prüft die Kürzung der Seitenleiste: +// höchstens navTagLimit Schlagworte plus Link auf die vollständige Liste. +func TestNavZeigtNurHaeufigsteSchlagworte(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + legeSchlagworteAn(t, db, projekt.ID, navTagLimit+5) + + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + // Geprüft wird auf den Text der Seitenleiste (nav-text) — die + // Eintragsliste selbst verlinkt die Schlagworte ihrer Einträge auch. + inLeiste := func(body string, i int) bool { + return strings.Contains(body, fmt.Sprintf(`tag-%02d`, i)) + } + + body := holeSeite(t, h, cookie, "/p/testprojekt/") + if !inLeiste(body, 0) { + t.Error("das häufigste Schlagwort fehlt in der Seitenleiste") + } + if inLeiste(body, navTagLimit+4) { + t.Error("die Seitenleiste zeigt mehr als navTagLimit Schlagworte") + } + if !strings.Contains(body, `href="/p/testprojekt/tags"`) { + t.Error("der Link auf alle Schlagworte fehlt") + } + + // Ist ein seltenes Schlagwort gewählt, muss es trotzdem sichtbar sein. + body = holeSeite(t, h, cookie, fmt.Sprintf("/p/testprojekt/?tag=tag-%02d", navTagLimit+4)) + if !inLeiste(body, navTagLimit+4) { + t.Error("das gewählte, seltene Schlagwort fehlt in der Seitenleiste") + } +} + +// TestNavOhneLinkBeiWenigenSchlagworten: Passen alle Schlagworte in die +// Seitenleiste, braucht es keinen Link auf die vollständige Liste. +func TestNavOhneLinkBeiWenigenSchlagworten(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + legeSchlagworteAn(t, db, projekt.ID, 3) + + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + body := holeSeite(t, h, cookie, "/p/testprojekt/") + if strings.Contains(body, `href="/p/testprojekt/tags"`) { + t.Error("Link auf alle Schlagworte, obwohl schon alle in der Seitenleiste stehen") + } +} + +// TestTagListSeite prüft die Seite mit allen Schlagworten in beiden +// Sortierungen. +func TestTagListSeite(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + legeSchlagworteAn(t, db, projekt.ID, navTagLimit+5) + + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + body := holeSeite(t, h, cookie, "/p/testprojekt/tags") + for i := 0; i < navTagLimit+5; i++ { + if !strings.Contains(body, fmt.Sprintf(`data-tag="tag-%02d"`, i)) { + t.Errorf("tag-%02d fehlt auf der Übersichtsseite", i) + } + } + if !strings.Contains(body, "data-tag-filter") { + t.Error("das Filterfeld fehlt") + } + + body = holeSeite(t, h, cookie, "/p/testprojekt/tags?sort=name") + if !strings.Contains(body, "

T

") { + t.Error("die alphabetische Ansicht zeigt keine Buchstabengruppe") + } +} + +func TestKuerzeTags(t *testing.T) { + tags := []store.TagCount{{Tag: "a", Count: 5}, {Tag: "b", Count: 4}, {Tag: "c", Count: 3}, {Tag: "d", Count: 1}} + + if got := kuerzeTags(tags, 10, ""); len(got) != 4 { + t.Errorf("unter dem Limit: %d Schlagworte, erwartet alle 4", len(got)) + } + if got := kuerzeTags(tags, 2, ""); len(got) != 2 || got[1].Tag != "b" { + t.Errorf("gekürzt: %v, erwartet a, b", got) + } + if got := kuerzeTags(tags, 2, "d"); len(got) != 3 || got[2].Tag != "d" { + t.Errorf("mit seltenem aktivem Schlagwort: %v, erwartet a, b, d", got) + } + if got := kuerzeTags(tags, 2, "a"); len(got) != 2 { + t.Errorf("aktives Schlagwort schon enthalten: %v, erwartet a, b", got) + } + // Die Eingabe darf nicht verändert werden. + if tags[2].Tag != "c" { + t.Error("kuerzeTags hat die Eingabeliste überschrieben") + } +} + +func TestGruppiereTags(t *testing.T) { + tags := []store.TagCount{{Tag: "zebra"}, {Tag: "übersicht"}, {Tag: "api"}, {Tag: "2fa"}, {Tag: "umzug"}} + gruppen := gruppiereTags(tags) + + var reihenfolge []string + for _, g := range gruppen { + var namen []string + for _, tc := range g.Tags { + namen = append(namen, tc.Tag) + } + reihenfolge = append(reihenfolge, g.Buchstabe+":"+strings.Join(namen, ",")) + } + got := strings.Join(reihenfolge, " ") + want := "#:2fa A:api U:übersicht,umzug Z:zebra" + if got != want { + t.Errorf("Gruppen = %q, erwartet %q", got, want) + } +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 11b5a61..a2ef3bf 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -124,13 +124,21 @@ func erzeugeKey(t *testing.T, srv *Server, db *store.Store, projectID, name stri return klartext } +// plattformSubjekt gibt das Session-Subject des Plattform-Admins zurück, so wie +// der Testserver es nach der Anmeldung mit testAdminToken vergibt — gebraucht, +// um passende CSRF-Tokens zu berechnen. +func plattformSubjekt(workspaceID string) string { + signer := auth.NewSigner([]byte(testSecret)) + return auth.PlatformSubject(signer.Fingerprint(auth.HashKey(testAdminToken)), workspaceID) +} + // waehleWorkspace wählt für eine Plattform-Admin-Sitzung einen Workspace und // gibt den neuen Session-Cookie zurück — der Schritt, den handleWorkspaceList // in der Oberfläche anbietet (siehe handlers_workspaces.go). func waehleWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie { t.Helper() - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt("")) formular := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}} r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(formular.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -444,7 +452,7 @@ func TestWorkspaceCreateSelectsItAndRedirects(t *testing.T) { cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) token := auth.NewSigner([]byte(testSecret)). - CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID) + CSRFToken(plattformSubjekt(projekt.WorkspaceID)) formular := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}} r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(formular.Encode())) @@ -528,7 +536,7 @@ func TestWorkspaceRename(t *testing.T) { cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) token := auth.NewSigner([]byte(testSecret)). - CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID) + CSRFToken(plattformSubjekt(projekt.WorkspaceID)) formular := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}} r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+projekt.WorkspaceID+"/edit", @@ -560,7 +568,7 @@ func TestWorkspaceDeleteRequiresConfirmation(t *testing.T) { cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) token := auth.NewSigner([]byte(testSecret)). - CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID) + CSRFToken(plattformSubjekt(projekt.WorkspaceID)) formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}} r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode())) @@ -592,7 +600,7 @@ func TestWorkspaceDeleteRemovesEverythingAndResetsSession(t *testing.T) { cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) token := auth.NewSigner([]byte(testSecret)). - CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID) + CSRFToken(plattformSubjekt(projekt.WorkspaceID)) formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}} r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode())) @@ -670,6 +678,87 @@ func TestPlatformAdminCanAccessAnyProject(t *testing.T) { } } +// statusMitCookie ruft eine Seite mit dem gegebenen Cookie ab und gibt den +// Status zurück. +func statusMitCookie(h http.Handler, pfad string, cookie *http.Cookie) int { + r := httptest.NewRequest(http.MethodGet, pfad, nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code +} + +// TestNeuesAdminTokenBeendetAdminSitzungen stellt den Ernstfall nach: Das +// ADMIN_TOKEN ist geleakt und wird ersetzt, der Server neu gestartet. Wer sich +// mit dem alten Token angemeldet hat, muss sofort draußen sein — Teams mit +// ihren Keys dürfen davon nichts merken. +func TestNeuesAdminTokenBeendetAdminSitzungen(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + ohneWorkspace := anmelden(t, h, testAdminToken) + mitWorkspace := waehleWorkspace(t, h, ohneWorkspace, projekt.WorkspaceID) + team := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "team", auth.RoleAdmin)) + + if code := statusMitCookie(h, "/p/testprojekt/", mitWorkspace); code != http.StatusOK { + t.Fatalf("vor dem Wechsel: Status %d, erwartet 200", code) + } + + // Neustart mit neuem ADMIN_TOKEN, aber demselben SESSION_SECRET und + // denselben Daten. + const neuesToken = "ein-ganz-neues-admin-token" + neu, err := NewServer(Config{ + Workspaces: srv.workspaces, + Control: srv.control, + Signer: srv.signer, + Cookies: srv.cookies, + Limiter: auth.NewRateLimiter(), + Templates: templatesForTest(t), + AdminToken: neuesToken, + WriteBudget: srv.writeBudget, + }) + if err != nil { + t.Fatal(err) + } + hNeu := testHandler(t, neu) + + for name, cookie := range map[string]*http.Cookie{ + "ohne Workspace": ohneWorkspace, + "mit Workspace": mitWorkspace, + } { + code := statusMitCookie(hNeu, "/admin/workspaces", cookie) + if code != http.StatusSeeOther { + t.Errorf("alte Admin-Sitzung %s: Status %d, erwartet Weiterleitung zur Anmeldung", name, code) + } + } + + if code := statusMitCookie(hNeu, "/p/testprojekt/", team); code != http.StatusOK { + t.Errorf("Sitzung eines Teams: Status %d, erwartet 200 — ein neues ADMIN_TOKEN darf Teams nicht abmelden", code) + } + + frisch := anmelden(t, hNeu, neuesToken) + if code := statusMitCookie(hNeu, "/admin/workspaces", frisch); code != http.StatusOK { + t.Errorf("Anmeldung mit dem neuen Token: Status %d, erwartet 200", code) + } +} + +// TestAlteAdminSitzungOhneFingerabdruckGiltNicht: Cookies aus der Zeit vor dem +// Fingerabdruck ("platform", "platform:") sind korrekt signiert, dürfen +// aber nicht mehr gelten — sonst bliebe genau die Lücke offen, die der +// Fingerabdruck schließt. +func TestAlteAdminSitzungOhneFingerabdruckGiltNicht(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + signer := auth.NewSigner([]byte(testSecret)) + + for _, subject := range []string{"platform", "platform:" + projekt.WorkspaceID} { + cookie := &http.Cookie{Name: auth.SessionCookieName, Value: signer.SignSession(subject)} + if code := statusMitCookie(h, "/admin/workspaces", cookie); code != http.StatusSeeOther { + t.Errorf("%q: Status %d, erwartet Weiterleitung zur Anmeldung", subject, code) + } + } +} + // ── Rollen ─────────────────────────────────────────────────────────────────── // TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang keine Einträge ändern kann. @@ -1800,7 +1889,7 @@ func adminPost(t *testing.T, h http.Handler, workspaceID, pfad string, formular cookie := anmeldenAlsPlattformAdmin(t, h, workspaceID) formular.Set(auth.CSRFFieldName, - auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin+":"+workspaceID)) + auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt(workspaceID))) r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -2079,7 +2168,7 @@ func TestExportKnopfImZweitenWorkspace(t *testing.T) { } cookie := anmeldenAlsPlattformAdmin(t, h, wsB.ID) - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin + ":" + wsB.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt(wsB.ID)) formular := url.Values{auth.CSRFFieldName: {token}} r := httptest.NewRequest(http.MethodPost, "/admin/export", strings.NewReader(formular.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") diff --git a/internal/web/workspace_admin_test.go b/internal/web/workspace_admin_test.go new file mode 100644 index 0000000..0f5ca5c --- /dev/null +++ b/internal/web/workspace_admin_test.go @@ -0,0 +1,478 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + + "munin/internal/auth" + "munin/internal/store" +) + +// Tests für den Workspace-Admin (AUSBAUSTUFEN.md 4.8). +// +// Der Schwerpunkt liegt auf dem, was er NICHT darf: Die Rolle ist nur dann +// ungefährlich, wenn sie an keiner Stelle über ihren Workspace, über die +// Weboberfläche oder über die Projekt- und Key-Verwaltung hinausreicht. + +// workspaceAdmin bündelt, was ein Test über einen angemeldeten +// Workspace-Admin wissen muss. +type workspaceAdmin struct { + token string + cookie *http.Cookie + csrf string +} + +// legeWorkspaceAdminAn erzeugt einen Workspace-Admin direkt in der +// Kontroll-Datenbank und gibt das Token im Klartext zurück — wie es der +// Plattform-Admin auf der Bearbeitungsseite des Workspace bekäme. +func legeWorkspaceAdminAn(t *testing.T, srv *Server, workspaceID, name string) (string, store.WorkspaceAdmin) { + t.Helper() + + token, err := auth.GenerateWorkspaceAdminToken() + if err != nil { + t.Fatal(err) + } + admin, err := srv.control.CreateWorkspaceAdmin(context.Background(), workspaceID, name, auth.HashKey(token)) + if err != nil { + t.Fatal(err) + } + return token, admin +} + +// anmeldenAlsWorkspaceAdmin legt einen Workspace-Admin an, meldet ihn an und +// berechnet sein CSRF-Token. +func anmeldenAlsWorkspaceAdmin(t *testing.T, srv *Server, h http.Handler, workspaceID string) workspaceAdmin { + t.Helper() + + token, admin := legeWorkspaceAdminAn(t, srv, workspaceID, "Erika") + subject := auth.SubjectWorkspaceAdminPrefix + workspaceID + ":" + admin.ID + + return workspaceAdmin{ + token: token, + cookie: anmelden(t, h, token), + csrf: auth.NewSigner([]byte(testSecret)).CSRFToken(subject), + } +} + +// get ruft eine Seite mit der Sitzung des Workspace-Admins auf. +func (a workspaceAdmin) get(h http.Handler, pfad string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, pfad, nil) + r.AddCookie(a.cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +// post schickt ein Formular mit gültigem CSRF-Token — abgewiesen werden darf +// es also nur an der Rechteprüfung, nicht am Token. +func (a workspaceAdmin) post(h http.Handler, pfad string, formular url.Values) *httptest.ResponseRecorder { + if formular == nil { + formular = url.Values{} + } + formular.Set(auth.CSRFFieldName, a.csrf) + + r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(a.cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +// ── Was er darf ────────────────────────────────────────────────────────────── + +func TestWorkspaceAdminLandsOnProjects(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + + w := admin.get(h, "/") + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/admin/projects" { + t.Fatalf("Startseite: Status %d, Ziel %q — erwartet Weiterleitung auf /admin/projects", + w.Code, w.Header().Get("Location")) + } + + w = admin.get(h, "/admin/projects") + if w.Code != http.StatusOK { + t.Fatalf("Projektliste: Status %d, erwartet 200", w.Code) + } + if !strings.Contains(w.Body.String(), projekt.Name) { + t.Error("die Projektliste zeigt das Projekt des Workspace nicht") + } + if strings.Contains(w.Body.String(), `action="/admin/export"`) { + t.Error("die Projektliste bietet dem Workspace-Admin den Export an") + } +} + +func TestWorkspaceAdminManagesProjects(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + ctx := context.Background() + + // Anlegen + w := admin.post(h, "/admin/projects/create", url.Values{"name": {"Neues Team"}, "slug": {"neu"}}) + if w.Code != http.StatusSeeOther { + t.Fatalf("Anlegen: Status %d, erwartet 303 — %s", w.Code, w.Body.String()) + } + neu, err := db.ProjectBySlug(ctx, projekt.WorkspaceID, "neu") + if err != nil { + t.Fatalf("das angelegte Projekt fehlt: %v", err) + } + + // Umbenennen + w = admin.post(h, "/admin/projects/neu/edit", url.Values{"name": {"Umbenannt"}, "description": {""}}) + if w.Code != http.StatusSeeOther { + t.Fatalf("Bearbeiten: Status %d, erwartet 303 — %s", w.Code, w.Body.String()) + } + if p, _ := db.ProjectByID(ctx, neu.ID); p.Name != "Umbenannt" { + t.Errorf("Name = %q, erwartet \"Umbenannt\"", p.Name) + } + + // Löschen + w = admin.post(h, "/admin/projects/delete", url.Values{"id": {neu.ID}, "confirm": {"neu"}}) + if w.Code != http.StatusSeeOther { + t.Fatalf("Löschen: Status %d, erwartet 303", w.Code) + } + if _, err := db.ProjectByID(ctx, neu.ID); err == nil { + t.Error("das Projekt ist nach dem Löschen noch da") + } + + // Im Protokoll steht der Workspace-Admin mit Namen, nicht "platform-admin". + ereignisse, err := db.ListAudit(ctx, store.AuditFilter{}) + if err != nil { + t.Fatal(err) + } + gefunden := false + for _, e := range ereignisse { + if e.Action == store.ActionProjectCreated { + gefunden = true + if e.Actor != "workspace-admin:Erika" { + t.Errorf("Actor = %q, erwartet \"workspace-admin:Erika\"", e.Actor) + } + } + } + if !gefunden { + t.Error("das Anlegen des Projekts fehlt im Protokoll") + } +} + +func TestWorkspaceAdminManagesKeysAndReads(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + + for _, pfad := range []string{"/p/testprojekt/", "/p/testprojekt/keys", "/p/testprojekt/audit", "/admin/audit"} { + if w := admin.get(h, pfad); w.Code != http.StatusOK { + t.Errorf("GET %s: Status %d, erwartet 200", pfad, w.Code) + } + } + + w := admin.post(h, "/p/testprojekt/keys/create", url.Values{"name": {"team-lead"}, "role": {"admin"}}) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), auth.KeyPrefix) { + t.Fatalf("Key anlegen: Status %d, oder kein Key in der Antwort", w.Code) + } + + keys, err := db.ListAPIKeys(context.Background(), projekt.ID) + if err != nil || len(keys) != 1 { + t.Fatalf("erwartet genau einen Key, gefunden %d (%v)", len(keys), err) + } + + w = admin.post(h, "/p/testprojekt/keys/revoke", url.Values{"id": {keys[0].ID}}) + if w.Code != http.StatusOK { + t.Fatalf("Key zurückziehen: Status %d, erwartet 200", w.Code) + } + if k, _ := db.APIKeyByID(context.Background(), keys[0].ID); !k.Revoked() { + t.Error("der Key ist nach dem Zurückziehen noch aktiv") + } +} + +// ── Was er nicht darf ──────────────────────────────────────────────────────── + +// TestWorkspaceAdminCannotReachPlatformRoutes geht jede Route durch, die dem +// Plattform-Admin vorbehalten bleibt. +func TestWorkspaceAdminCannotReachPlatformRoutes(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + id := projekt.WorkspaceID + + gets := []string{ + "/admin/workspaces", + "/admin/workspaces/" + id + "/edit", + } + for _, pfad := range gets { + if w := admin.get(h, pfad); w.Code != http.StatusForbidden { + t.Errorf("GET %s: Status %d, erwartet 403", pfad, w.Code) + } + } + + posts := map[string]url.Values{ + "/admin/workspaces/create": {"name": {"Eigener"}}, + "/admin/workspaces/select": {"id": {id}}, + "/admin/workspaces/delete": {"id": {id}, "confirm": {"Test"}}, + "/admin/workspaces/" + id + "/edit": {"name": {"Umbenannt"}}, + "/admin/workspaces/" + id + "/admins/create": {"name": {"Zweiter"}}, + "/admin/workspaces/" + id + "/admins/revoke": {"id": {"egal"}}, + "/admin/export": nil, + } + for pfad, formular := range posts { + if w := admin.post(h, pfad, formular); w.Code != http.StatusForbidden { + t.Errorf("POST %s: Status %d, erwartet 403", pfad, w.Code) + } + } + + // Kein zweiter Workspace-Admin durch die Hintertür. + admins, err := srv.control.ListWorkspaceAdmins(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if len(admins) != 1 { + t.Errorf("%d Workspace-Admins, erwartet 1", len(admins)) + } +} + +// TestWorkspaceAdminCannotWriteEntries: Lesen ja, Schreiben nein — auch nicht +// über Import, Papierkorb oder Wiederherstellen. +func TestWorkspaceAdminCannotWriteEntries(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + + for _, pfad := range []string{"/p/testprojekt/new", "/p/testprojekt/import", "/p/testprojekt/trash"} { + if w := admin.get(h, pfad); w.Code != http.StatusForbidden { + t.Errorf("GET %s: Status %d, erwartet 403", pfad, w.Code) + } + } + + w := admin.post(h, "/p/testprojekt/save", url.Values{ + "slug": {"neu"}, "title": {"Neu"}, "content": {"Text"}, + }) + if w.Code != http.StatusForbidden { + t.Errorf("Eintrag speichern: Status %d, erwartet 403", w.Code) + } + + // Die Seitenleiste bietet weder Import noch Papierkorb an. + body := admin.get(h, "/p/testprojekt/").Body.String() + for _, link := range []string{`href="/p/testprojekt/import"`, `href="/p/testprojekt/trash"`, `href="/p/testprojekt/new"`} { + if strings.Contains(body, link) { + t.Errorf("die Seite verlinkt %s, obwohl der Workspace-Admin dort abgewiesen wird", link) + } + } +} + +// TestWorkspaceAdminCannotChangeWriteBudget: Das Feld wird nicht angeboten, +// und ein von Hand nachgebautes Formular ändert trotzdem nichts. +func TestWorkspaceAdminCannotChangeWriteBudget(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + + formularSeite := admin.get(h, "/admin/projects/testprojekt/edit") + if formularSeite.Code != http.StatusOK { + t.Fatalf("Bearbeitungsseite: Status %d, erwartet 200", formularSeite.Code) + } + if strings.Contains(formularSeite.Body.String(), `name="write_limit_hourly"`) { + t.Error("die Bearbeitungsseite bietet dem Workspace-Admin das Schreibbudget an") + } + + w := admin.post(h, "/admin/projects/testprojekt/edit", url.Values{ + "name": {"Neuer Name"}, "description": {""}, "write_limit_hourly": {"99999"}, + }) + if w.Code != http.StatusSeeOther { + t.Fatalf("Speichern: Status %d, erwartet 303 — %s", w.Code, w.Body.String()) + } + + gelesen, err := db.ProjectByID(context.Background(), projekt.ID) + if err != nil { + t.Fatal(err) + } + if gelesen.WriteLimitHourly != projekt.WriteLimitHourly { + t.Errorf("WriteLimitHourly = %d, erwartet unverändert %d", gelesen.WriteLimitHourly, projekt.WriteLimitHourly) + } + if gelesen.Name != "Neuer Name" { + t.Errorf("Name = %q — die übrigen Felder sollen trotzdem gespeichert werden", gelesen.Name) + } +} + +// TestWorkspaceAdminStaysInOwnWorkspace: Projekte eines anderen Workspace +// sind für ihn nicht vorhanden — weder unter /p/ noch in der Verwaltung. +func TestWorkspaceAdminStaysInOwnWorkspace(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + wsB, err := srv.control.CreateWorkspace(ctx, "Andere Abteilung") + if err != nil { + t.Fatal(err) + } + workspaceB, err := srv.workspaces.Get(wsB.ID) + if err != nil { + t.Fatal(err) + } + projektB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "") + if err != nil { + t.Fatal(err) + } + + admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + + for _, pfad := range []string{"/p/fremd/", "/p/fremd/keys", "/admin/projects/fremd/edit"} { + if w := admin.get(h, pfad); w.Code != http.StatusNotFound { + t.Errorf("GET %s: Status %d, erwartet 404", pfad, w.Code) + } + } + + if w := admin.post(h, "/admin/projects/delete", url.Values{"id": {projektB.ID}, "confirm": {"fremd"}}); w.Code != http.StatusNotFound { + t.Errorf("fremdes Projekt löschen: Status %d, erwartet 404", w.Code) + } + if _, err := workspaceB.Store.ProjectByID(ctx, projektB.ID); err != nil { + t.Error("das Projekt aus Workspace B wurde gelöscht") + } + + if strings.Contains(admin.get(h, "/admin/projects").Body.String(), "Fremdes Projekt") { + t.Error("die Projektliste zeigt ein Projekt aus einem anderen Workspace") + } +} + +// TestWorkspaceAdminTokenRejectedByAPI: Das Token taugt nur für die +// Weboberfläche, nicht für MCP. +func TestWorkspaceAdminTokenRejectedByAPI(t *testing.T) { + srv, _, projekt := testServer(t) + token, _ := legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Erika") + + var erreicht bool + h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true })) + + r := httptest.NewRequest(http.MethodPost, "/mcp", nil) + r.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if erreicht { + t.Fatal("ein Workspace-Admin-Token kam an der MCP-Schnittstelle durch") + } + if w.Code != http.StatusUnauthorized { + t.Errorf("Status = %d, erwartet 401", w.Code) + } +} + +// ── Anlegen und Zurückziehen durch den Plattform-Admin ────────────────────── + +func TestPlatformAdminIssuesAndRevokesWorkspaceAdmin(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + pfad := "/admin/workspaces/" + projekt.WorkspaceID + "/admins/" + + w := adminPost(t, h, projekt.WorkspaceID, pfad+"create", url.Values{"name": {"Erika"}}) + if w.Code != http.StatusOK { + t.Fatalf("Anlegen: Status %d, erwartet 200 — %s", w.Code, w.Body.String()) + } + + // Das Token steht genau einmal in der Antwort — herausfischen und damit anmelden. + body := w.Body.String() + start := strings.Index(body, auth.WorkspaceAdminPrefix) + if start < 0 { + t.Fatal("die Antwort enthält kein Token") + } + token := body[start : start+len(auth.WorkspaceAdminPrefix)+64] + if !auth.LooksLikeWorkspaceAdminToken(token) { + t.Fatalf("%q sieht nicht wie ein Workspace-Admin-Token aus", token) + } + + cookie := anmelden(t, h, token) + + admins, err := srv.control.ListWorkspaceAdmins(context.Background(), projekt.WorkspaceID) + if err != nil || len(admins) != 1 { + t.Fatalf("erwartet genau einen Workspace-Admin, gefunden %d (%v)", len(admins), err) + } + + w = adminPost(t, h, projekt.WorkspaceID, pfad+"revoke", url.Values{"id": {admins[0].ID}}) + if w.Code != http.StatusOK { + t.Fatalf("Zurückziehen: Status %d, erwartet 200", w.Code) + } + + // Die laufende Sitzung endet sofort. + r := httptest.NewRequest(http.MethodGet, "/admin/projects", nil) + r.AddCookie(cookie) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/login" { + t.Errorf("nach dem Zurückziehen: Status %d, Ziel %q — erwartet Weiterleitung zur Anmeldung", + w.Code, w.Header().Get("Location")) + } + + // Und eine neue Anmeldung klappt nicht mehr. + formular := url.Values{"key": {token}} + r = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Errorf("Anmeldung mit zurückgezogenem Token: Status %d, erwartet 401", w.Code) + } +} + +// TestWorkspaceListCountsActiveAdmins: Die Übersicht zählt nur aktive +// Workspace-Admins, und ein frisch angelegter gilt nicht als „ungenutzt“. +func TestWorkspaceListCountsActiveAdmins(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Erika") + legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Max") + _, weg := legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Ehemalig") + if err := srv.control.RevokeWorkspaceAdmin(ctx, projekt.WorkspaceID, weg.ID); err != nil { + t.Fatal(err) + } + + cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + r := httptest.NewRequest(http.MethodGet, "/admin/workspaces", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, erwartet 200", w.Code) + } + body := w.Body.String() + if !regexp.MustCompile(`\s*2\s*`).MatchString(body) { + t.Error("die Übersicht zeigt nicht 2 aktive Workspace-Admins") + } + if strings.Contains(body, "ungenutzt") { + t.Error("frisch angelegte Workspace-Admins sind als ungenutzt markiert") + } +} + +// TestWorkspaceAdminRevokeRejectsForeignID:Über die Bearbeitungsseite von +// Workspace A lässt sich kein Zugang aus Workspace B zurückziehen. +func TestWorkspaceAdminRevokeRejectsForeignID(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + + wsB, err := srv.control.CreateWorkspace(context.Background(), "Andere Abteilung") + if err != nil { + t.Fatal(err) + } + _, fremd := legeWorkspaceAdminAn(t, srv, wsB.ID, "Fremd") + + w := adminPost(t, h, projekt.WorkspaceID, + "/admin/workspaces/"+projekt.WorkspaceID+"/admins/revoke", url.Values{"id": {fremd.ID}}) + if w.Code != http.StatusNotFound { + t.Errorf("Status = %d, erwartet 404", w.Code) + } + + gelesen, err := srv.control.WorkspaceAdminByID(context.Background(), fremd.ID) + if err != nil { + t.Fatal(err) + } + if gelesen.Revoked() { + t.Error("der Zugang aus Workspace B wurde zurückgezogen") + } +} diff --git a/last_test.go b/last_test.go new file mode 100644 index 0000000..3b8b8f8 --- /dev/null +++ b/last_test.go @@ -0,0 +1,425 @@ +//go:build last + +// Lasttest: viele Agenten greifen gleichzeitig über echtes HTTP und MCP zu. +// +// Er läuft NICHT bei jedem "go test ./...", weil er Zeit braucht und die +// Maschine auslastet. Das Build-Tag oben schaltet ihn erst ein, wenn man +// ausdrücklich danach fragt: +// +// go test -tags last -run TestLast -v . +// +// Mit eigenen Werten (alle Schalter siehe die flag.-Zeilen unten): +// +// go test -tags last -run TestLast -v . -last.agenten=64 -last.dauer=30s +// +// Drei Aufstellungen mit derselben Zahl Agenten, damit die Zahlen vergleichbar +// sind: +// +// - ein Projekt: alle Agenten im selben Projekt +// - ein Workspace, mehrere Projekte: teilen sich eine Datei und damit die +// eine Schreibverbindung (siehe openPool in internal/store/store.go) +// - mehrere Workspaces: je eine eigene Datei, schreiben echt parallel +// +// Am Ende prüft jede Aufstellung, ob nichts verloren ging und ob der +// Suchindex zum Stand der Datenbank passt. +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "log/slog" + "math/rand/v2" + "net/http" + "net/http/httptest" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "munin/internal/auth" + "munin/internal/search" + "munin/internal/store" + "munin/internal/workspace" +) + +var ( + lastAgenten = flag.Int("last.agenten", 32, "Zahl der gleichzeitigen Agenten je Aufstellung") + lastDauer = flag.Duration("last.dauer", 10*time.Second, "Laufzeit je Aufstellung") + lastProjekte = flag.Int("last.projekte", 4, "Projekte bzw. Workspaces in den Aufstellungen mit mehreren") + lastEintraege = flag.Int("last.eintraege", 500, "Einträge, die jedes Projekt vorab bekommt") + lastSlugs = flag.Int("last.slugs", 20, "Bezeichner, auf die die Agenten je Projekt schreiben — klein heißt viel Gedränge auf denselben Einträgen") + lastSchreiben = flag.Int("last.schreibanteil", 30, "Anteil schreibender Aufrufe in Prozent, der Rest ist recall und get") +) + +func TestLast(t *testing.T) { + // Jede Anfrage würde sonst eine Logzeile schreiben — bei zehntausenden + // Aufrufen geht die Auswertung darin unter. + log.SetOutput(io.Discard) + slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) + + n := *lastProjekte + aufstellungen := []struct { + name string + workspaces, projekte int // projekte = je Workspace + }{ + {"ein Projekt", 1, 1}, + {fmt.Sprintf("ein Workspace, %d Projekte", n), 1, n}, + {fmt.Sprintf("%d Workspaces, je 1 Projekt", n), n, 1}, + } + + for _, a := range aufstellungen { + t.Run(a.name, func(t *testing.T) { + lauf(t, a.workspaces, a.projekte) + }) + } +} + +// ziel ist ein Projekt samt den Keys, mit denen Agenten es ansprechen. +type ziel struct { + ws *workspace.Workspace + projekt store.Project + keys []string +} + +// lauf baut eine Aufstellung auf, lässt die Agenten laufen und wertet aus. +func lauf(t *testing.T, anzahlWorkspaces, projekteJeWorkspace int) { + ctx := context.Background() + dir := t.TempDir() + + control, err := store.OpenControl(filepath.Join(dir, "control.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { control.Close() }) + workspaces := workspace.NewManager(dir) + t.Cleanup(func() { workspaces.Close() }) + + // Dieselbe Verdrahtung wie im Betrieb. Schreibbudget 0 = unbegrenzt — es + // soll eine Schleife stoppen und würde hier die Messung abwürgen. + handler, err := baueHandler(control, workspaces, nil, config{ + sessionSecret: strings.Repeat("x", 32), + adminToken: strings.Repeat("y", 32), + maxAPIBody: defaultMaxAPIBodyBytes, + writeLimitHourly: 0, + }) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + // ── Daten anlegen ──────────────────────────────────────────────────────── + agenten := *lastAgenten + anzahlZiele := anzahlWorkspaces * projekteJeWorkspace + var ziele []*ziel + + for w := 0; w < anzahlWorkspaces; w++ { + wsEintrag, err := control.CreateWorkspace(ctx, fmt.Sprintf("Workspace %d", w)) + if err != nil { + t.Fatal(err) + } + ws, err := workspaces.Get(wsEintrag.ID) + if err != nil { + t.Fatal(err) + } + for p := 0; p < projekteJeWorkspace; p++ { + projekt, err := ws.Store.CreateProject(ctx, wsEintrag.ID, fmt.Sprintf("projekt-%d", p), "Projekt", "") + if err != nil { + t.Fatal(err) + } + z := &ziel{ws: ws, projekt: projekt} + ziele = append(ziele, z) + + for i := 0; i < *lastEintraege; i++ { + if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{ + ProjectID: projekt.ID, Slug: fmt.Sprintf("vorab-%04d", i), Title: fmt.Sprintf("Vorab %d", i), + Content: fuellText(i), Source: store.SourceHuman, + }); err != nil { + t.Fatal(err) + } + } + } + } + + // Jeder Agent bekommt einen eigenen Key, wie ein echter Nutzer. + for a := 0; a < agenten; a++ { + z := ziele[a%anzahlZiele] + klartext, err := auth.GenerateKey() + if err != nil { + t.Fatal(err) + } + hash := auth.HashKey(klartext) + if _, err := z.ws.Store.CreateAPIKey(ctx, z.projekt.ID, fmt.Sprintf("agent-%d", a), hash, string(auth.RoleWrite)); err != nil { + t.Fatal(err) + } + if err := control.IndexKey(ctx, hash, z.ws.ID); err != nil { + t.Fatal(err) + } + z.keys = append(z.keys, klartext) + } + + // ── Agenten laufen lassen ──────────────────────────────────────────────── + // Ein gemeinsamer Transport, der je Agent eine Verbindung offen hält. Mit + // den Vorgaben von Go (2 offene Verbindungen je Ziel) öffnen die Agenten + // sonst laufend neue, bis dem Testrechner die lokalen Ports ausgehen + // ("can't assign requested address"). Echte Agenten sitzen auf + // verschiedenen Rechnern und kennen das Problem nicht — es wäre also ein + // Fehler der Messung, nicht des Servers. + httpTransport := http.DefaultTransport.(*http.Transport).Clone() + httpTransport.MaxIdleConnsPerHost = agenten + t.Cleanup(httpTransport.CloseIdleConnections) + + messung := newMessung() + ende := time.Now().Add(*lastDauer) + var wg sync.WaitGroup + + for a := 0; a < agenten; a++ { + z := ziele[a%anzahlZiele] + key := z.keys[a/anzahlZiele] + wg.Add(1) + go func(a int) { + defer wg.Done() + agent(ctx, httpTransport, server.URL, key, a, ende, messung) + }(a) + } + wg.Wait() + + // ── Auswerten ──────────────────────────────────────────────────────────── + messung.bericht(t, *lastDauer) + + if anzahl, erste := messung.fehler(); anzahl > 0 { + t.Errorf("%d fehlgeschlagene Aufrufe, erste: %v", anzahl, erste) + } + + pruefeBestand(t, ziele, messung.erfolgreicheSchreibvorgaenge()) +} + +// agent verhält sich wie ein Agent in einer Schleife: suchen, lesen, +// gelegentlich schreiben — bis die Zeit um ist. +func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string, nr int, ende time.Time, m *messung) { + httpClient := &http.Client{ + Transport: &mitKey{key: key, next: httpTransport}, + Timeout: 30 * time.Second, + } + transport := &mcp.StreamableClientTransport{ + Endpoint: url + "/mcp", + HTTPClient: httpClient, + // Der zusätzliche Rückkanal für Nachrichten vom Server wird hier nicht + // gebraucht und würde nur eine weitere offene Verbindung je Agent kosten. + DisableStandaloneSSE: true, + } + client := mcp.NewClient(&mcp.Implementation{Name: fmt.Sprintf("agent-%d", nr), Version: "1"}, nil) + + start := time.Now() + sitzung, err := client.Connect(ctx, transport, nil) + m.erfasse("verbinden", time.Since(start), err) + if err != nil { + return + } + defer sitzung.Close() + + zufall := rand.New(rand.NewPCG(uint64(nr), 42)) + for i := 0; time.Now().Before(ende); i++ { + var werkzeug string + var args map[string]any + + switch w := zufall.IntN(100); { + case w < *lastSchreiben: + werkzeug = "remember" + // Ein eindeutiges Wort je Schreibvorgang — daran erkennt + // pruefeBestand, ob der Suchindex den letzten Stand kennt. + args = map[string]any{ + "slug": fmt.Sprintf("gemeinsam-%02d", zufall.IntN(*lastSlugs)), + "title": "Gemeinsamer Eintrag", + "content": "Stand " + eindeutigesWort(nr, i) + "\n\n" + fuellText(i), + } + case w < *lastSchreiben+(100-*lastSchreiben)*2/3: + werkzeug = "recall" + args = map[string]any{"query": suchbegriffe[zufall.IntN(len(suchbegriffe))]} + default: + werkzeug = "get" + args = map[string]any{"slug": fmt.Sprintf("vorab-%04d", zufall.IntN(max(1, *lastEintraege)))} + } + + start := time.Now() + erg, err := sitzung.CallTool(ctx, &mcp.CallToolParams{Name: werkzeug, Arguments: args}) + if err == nil && erg.IsError { + err = fmt.Errorf("%s: %s", werkzeug, ergebnisText(erg)) + } + m.erfasse(werkzeug, time.Since(start), err) + } +} + +// pruefeBestand vergleicht am Ende Datenbank und Suchindex. +// +// Zwei Dinge müssen stimmen: +// - Kein Schreibvorgang ging verloren: Jeder erfolgreiche remember-Aufruf +// hat eine Fassung erzeugt. Die Summe der Fassungen aller umkämpften +// Einträge muss also genau die Zahl der erfolgreichen Aufrufe sein. +// - Der Suchindex kennt den letzten Stand: Das eindeutige Wort der letzten +// Fassung muss den Eintrag finden. +func pruefeBestand(t *testing.T, ziele []*ziel, erfolgreich int) { + t.Helper() + ctx := context.Background() + + summeFassungen, veraltet := 0, 0 + for _, z := range ziele { + idx, err := z.ws.Search.For(ctx, z.projekt.ID) + if err != nil { + t.Fatal(err) + } + for s := 0; s < *lastSlugs; s++ { + slug := fmt.Sprintf("gemeinsam-%02d", s) + e, err := z.ws.Store.EntryBySlug(ctx, z.projekt.ID, slug) + if err != nil { + continue // nie beschrieben + } + summeFassungen += e.Version + + wort := strings.Fields(e.Content)[1] + gefunden := slices.ContainsFunc(idx.Search(wort, 5), func(r search.Result) bool { return r.DocID == slug }) + if !gefunden { + veraltet++ + } + } + } + + if summeFassungen != erfolgreich { + t.Errorf("%d erfolgreiche remember-Aufrufe, aber %d Fassungen gespeichert", erfolgreich, summeFassungen) + } + if veraltet > 0 { + t.Errorf("%d Einträge: Der Suchindex kennt die letzte Fassung nicht", veraltet) + } + t.Logf("Bestand geprüft: %d Fassungen, Suchindex %s", summeFassungen, + map[bool]string{true: "aktuell", false: "VERALTET"}[veraltet == 0]) +} + +// ── Messung ────────────────────────────────────────────────────────────────── + +// messung sammelt Dauer und Fehler aller Aufrufe, getrennt nach Werkzeug. +type messung struct { + mu sync.Mutex + dauern map[string][]time.Duration + fehl map[string]int + erste []error +} + +func newMessung() *messung { + return &messung{dauern: map[string][]time.Duration{}, fehl: map[string]int{}} +} + +func (m *messung) erfasse(werkzeug string, d time.Duration, err error) { + m.mu.Lock() + defer m.mu.Unlock() + if err != nil { + m.fehl[werkzeug]++ + if len(m.erste) < 10 { + m.erste = append(m.erste, err) + } + return + } + m.dauern[werkzeug] = append(m.dauern[werkzeug], d) +} + +// fehler gibt die Zahl aller fehlgeschlagenen Aufrufe und die ersten davon +// zurück. +func (m *messung) fehler() (anzahl int, erste []error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, n := range m.fehl { + anzahl += n + } + return anzahl, m.erste +} + +func (m *messung) erfolgreicheSchreibvorgaenge() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.dauern["remember"]) +} + +// bericht schreibt eine Tabelle: Aufrufe je Sekunde und wie lange die +// typische (p50), die langsamsten 5 % (p95) und 1 % (p99) Aufrufe dauerten. +func (m *messung) bericht(t *testing.T, dauer time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + + var b strings.Builder + fmt.Fprintf(&b, "\n%-10s %8s %8s %9s %9s %9s %9s %7s\n", "Werkzeug", "Aufrufe", "je s", "p50", "p95", "p99", "max", "Fehler") + for _, w := range []string{"verbinden", "recall", "get", "remember"} { + d := m.dauern[w] + slices.Sort(d) + je := float64(len(d)) / dauer.Seconds() + if w == "verbinden" { + je = 0 // einmal je Agent, keine Rate + } + fmt.Fprintf(&b, "%-10s %8d %8.0f %9s %9s %9s %9s %7d\n", w, len(d), je, + perzentil(d, 50), perzentil(d, 95), perzentil(d, 99), perzentil(d, 100), m.fehl[w]) + } + t.Log(b.String()) +} + +func perzentil(sortiert []time.Duration, p int) string { + if len(sortiert) == 0 { + return "-" + } + i := (len(sortiert) - 1) * p / 100 + return sortiert[i].Round(10 * time.Microsecond).String() +} + +// ── Hilfen ─────────────────────────────────────────────────────────────────── + +// mitKey hängt den Bearer-Key an jede Anfrage — so wie es ein MCP-Client mit +// "headers" in seiner Konfiguration tut. +type mitKey struct { + key string + next http.RoundTripper +} + +func (m *mitKey) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Header.Set("Authorization", "Bearer "+m.key) + return m.next.RoundTrip(r) +} + +func ergebnisText(r *mcp.CallToolResult) string { + var b strings.Builder + for _, c := range r.Content { + if t, ok := c.(*mcp.TextContent); ok { + b.WriteString(t.Text) + } + } + return b.String() +} + +// eindeutigesWort bildet aus Agent und Durchlauf ein Wort nur aus Buchstaben, +// damit der Tokenizer es nicht an Ziffern zerlegt. +func eindeutigesWort(agent, durchlauf int) string { + s := []byte(fmt.Sprintf("%04d%07d", agent, durchlauf)) + for i := range s { + s[i] = 'a' + (s[i] - '0') + } + return "marke" + string(s) +} + +var suchbegriffe = []string{"deployment", "server", "datenbank", "zugang", "backup", "monitoring", "release", "fehler"} + +// fuellText erzeugt einen Absatz mit wechselnden Fachbegriffen, damit der +// Suchindex realistisch viele Begriffe und Treffer hat. +func fuellText(i int) string { + var b strings.Builder + for j := 0; j < 40; j++ { + b.WriteString(suchbegriffe[(i+j*3)%len(suchbegriffe)]) + b.WriteString(" wird hier beschrieben, mit Details zu Schritt ") + b.WriteString(fmt.Sprint(j)) + b.WriteString(". ") + } + return b.String() +} diff --git a/main.go b/main.go index f4a9fb3..c328e12 100644 --- a/main.go +++ b/main.go @@ -179,59 +179,11 @@ func main() { return } - // ── Bestandteile ───────────────────────────────────────────────────────── - signer := auth.NewSigner([]byte(cfg.sessionSecret)) - cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE")) - limiter := auth.NewRateLimiter() - writeBudget := auth.NewWriteBudget(cfg.writeLimitHourly, writeLimitWindow) - - webServer, err := web.NewServer(web.Config{ - Workspaces: workspaces, - Control: control, - Signer: signer, - Cookies: cookies, - Limiter: limiter, - Templates: templatesFS, - AdminToken: cfg.adminToken, - Exporter: exporter, - WriteBudget: writeBudget, - }) - if err != nil { - log.Fatal("Templates konnten nicht geladen werden: ", err) - } - - mcpHandler := mcpserver.New(writeBudget) - - // ── Routen ─────────────────────────────────────────────────────────────── - staticSub, err := fs.Sub(staticFS, "static") + handler, err := baueHandler(control, workspaces, exporter, cfg) if err != nil { log.Fatal(err) } - mux := http.NewServeMux() - webServer.Routes(mux, staticSub) - - // Der MCP-Endpunkt hängt an der Key-Prüfung statt an der Sitzung — hier - // kommen keine Browser an, sondern KI-Werkzeuge mit einem Bearer-Key. - mux.Handle("/mcp", webServer.APIAuth(mcpHandler)) - - // Reihenfolge von außen nach innen: - // LogRequests erfasst jede Antwort, auch abgewiesene - // SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten - // LimitBodies deckelt den Body, bevor ein Handler ihn liest — mit - // einer eigenen Grenze für /mcp und den Import, siehe - // dort, warum das nicht je Route geschehen kann - handler := web.LogRequests( - web.SecurityHeaders( - web.LimitBodies(mux, web.BodyLimits{ - Web: maxWebBodyBytes, - API: cfg.maxAPIBody, - Import: maxImportBodyBytes, - }), - cfg.enableHSTS, - ), - ) - // ── Wiederkehrende Aufgaben ────────────────────────────────────────────── // // Beide laufen bei jedem Durchgang über ALLE Workspaces — auch über die, @@ -291,6 +243,67 @@ func main() { log.Println("Server gestoppt.") } +// baueHandler setzt Weboberfläche, MCP-Endpunkt und die Middleware davor zu +// einem HTTP-Handler zusammen. +// +// Eine eigene Funktion statt Teil von main, damit der Lasttest (last_test.go) +// über genau dieselbe Verdrahtung läuft wie der Betrieb — und nicht über einen +// nachgebauten Weg, der sich irgendwann unbemerkt unterscheidet. +func baueHandler(control *store.ControlStore, workspaces *workspace.Manager, exporter *export.Exporter, cfg config) (http.Handler, error) { + // ── Bestandteile ───────────────────────────────────────────────────────── + signer := auth.NewSigner([]byte(cfg.sessionSecret)) + cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE")) + limiter := auth.NewRateLimiter() + writeBudget := auth.NewWriteBudget(cfg.writeLimitHourly, writeLimitWindow) + + webServer, err := web.NewServer(web.Config{ + Workspaces: workspaces, + Control: control, + Signer: signer, + Cookies: cookies, + Limiter: limiter, + Templates: templatesFS, + AdminToken: cfg.adminToken, + Exporter: exporter, + WriteBudget: writeBudget, + }) + if err != nil { + return nil, fmt.Errorf("Templates konnten nicht geladen werden: %w", err) + } + + mcpHandler := mcpserver.New(writeBudget) + + // ── Routen ─────────────────────────────────────────────────────────────── + staticSub, err := fs.Sub(staticFS, "static") + if err != nil { + return nil, err + } + + mux := http.NewServeMux() + webServer.Routes(mux, staticSub) + + // Der MCP-Endpunkt hängt an der Key-Prüfung statt an der Sitzung — hier + // kommen keine Browser an, sondern KI-Werkzeuge mit einem Bearer-Key. + mux.Handle("/mcp", webServer.APIAuth(mcpHandler)) + + // Reihenfolge von außen nach innen: + // LogRequests erfasst jede Antwort, auch abgewiesene + // SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten + // LimitBodies deckelt den Body, bevor ein Handler ihn liest — mit + // einer eigenen Grenze für /mcp und den Import, siehe + // dort, warum das nicht je Route geschehen kann + return web.LogRequests( + web.SecurityHeaders( + web.LimitBodies(mux, web.BodyLimits{ + Web: maxWebBodyBytes, + API: cfg.maxAPIBody, + Import: maxImportBodyBytes, + }), + cfg.enableHSTS, + ), + ), nil +} + // config sammelt alle Einstellungen aus der Umgebung. type config struct { // dataDir enthält die Kontroll-Datenbank (control.db) und den Ordner diff --git a/scripts/backup.sh b/scripts/backup.sh index 4608f15..073aa8b 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -34,6 +34,10 @@ # Platz unter DATA_DIR zurückkopieren, Server wieder starten. Für einen # einzelnen Workspace reicht dessen Datei unter workspaces/ — die # Kontroll-Datenbank bleibt davon unberührt. +# +# Vorher die -wal- und -shm-Dateien der alten Fassung entfernen, sonst +# übernimmt SQLite deren Inhalt in die zurückgespielte Datei. Die genauen +# Schritte stehen in BETRIEB.md, Abschnitt "Aus einer Sicherung wiederherstellen". set -euo pipefail diff --git a/static/graph.js b/static/graph.js index c569104..999eb9d 100644 --- a/static/graph.js +++ b/static/graph.js @@ -9,11 +9,29 @@ // fortlaufend animiert: Bei der serverseitig gedeckelten Knotenzahl (siehe // graphNodeLimit in internal/web/handlers_entries.go) ist das in Bruchteilen // einer Sekunde fertig. +// +// Damit der Graph auch bei vielen Knoten lesbar bleibt, gelten drei Regeln: +// +// 1. Zoomen zieht die Knoten auseinander, statt alles zu vergrößern. +// Punkte und Schrift behalten ihre Bildschirmgröße — wer hineinzoomt, +// schafft also tatsächlich Platz zwischen den Knoten. +// 2. Beschriftet wird nur, was Platz hat. Die am stärksten verknüpften +// Knoten kommen zuerst an die Reihe; eine Beschriftung, die eine schon +// gesetzte oder einen fremden Punkt überdecken würde, bleibt weg (siehe +// beschrifte()). Je weiter man hineinzoomt, desto mehr Namen erscheinen. +// 3. Zeigt die Maus auf einen Knoten, treten er und seine direkten Nachbarn +// hervor und werden bevorzugt beschriftet; alles andere wird blass. (function () { "use strict"; var SVG_NS = "http://www.w3.org/2000/svg"; + // Ungefähre Höhe einer Beschriftungszeile in Bildschirmpixeln, samt + // etwas Luft — nur für die Überlappungsprüfung in beschrifte(). + var ZEILENHOEHE = 16; + // Abstand zwischen Punkt und Beschriftung. + var TEXTABSTAND = 4; + document.querySelectorAll("svg[data-graph]").forEach(function (svg) { ladeUndZeichne(svg); }); @@ -59,8 +77,11 @@ return; } + // Die viewBox entspricht der tatsächlichen Größe beim Laden — eine + // viewBox-Einheit ist damit ein Bildschirmpixel, und Schriftgrößen + // sowie Radien unten lassen sich direkt in Pixeln denken. var breite = svg.clientWidth || 800; - var hoehe = 560; + var hoehe = svg.clientHeight || 560; svg.setAttribute("viewBox", "0 0 " + breite + " " + hoehe); var indexBySlug = {}; @@ -75,43 +96,70 @@ return p[0] !== undefined && p[1] !== undefined; }); + // Nachbarn je Knoten — für die Hervorhebung beim Überfahren. + var nachbarn = knoten.map(function () { + return []; + }); + kantenIdx.forEach(function (paar) { + nachbarn[paar[0]].push(paar[1]); + nachbarn[paar[1]].push(paar[0]); + }); + + var radien = knoten.map(function (k) { + return 6 + Math.min(14, Math.sqrt(k.degree || 0) * 3); + }); + + // rangfolge: Knotenindizes, am stärksten verknüpfte zuerst. Diese + // Reihenfolge entscheidet, wer bei Platzmangel beschriftet wird. + // Vorhandene Einträge gehen fehlenden Verweiszielen vor, bei + // Gleichstand entscheidet der Titel — so bleibt die Auswahl bei + // jedem Laden dieselbe. + var rangfolge = knoten.map(function (_, i) { + return i; + }); + rangfolge.sort(function (a, b) { + var ka = knoten[a]; + var kb = knoten[b]; + if ((kb.degree || 0) !== (ka.degree || 0)) { + return (kb.degree || 0) - (ka.degree || 0); + } + if (ka.exists !== kb.exists) { + return ka.exists ? -1 : 1; + } + return ka.title < kb.title ? -1 : ka.title > kb.title ? 1 : 0; + }); + var slugs = knoten.map(function (k) { return k.slug; }); - var positionen = simuliere(knoten.length, kantenIdx, breite, hoehe, slugs); - - var viewport = svgEl("g", {}); - svg.appendChild(viewport); + var positionen = simuliere(knoten.length, kantenIdx, radien, slugs); var kantenGruppe = svgEl("g", { class: "graph-kanten" }); var knotenGruppe = svgEl("g", { class: "graph-knoten" }); - viewport.appendChild(kantenGruppe); - viewport.appendChild(knotenGruppe); + svg.appendChild(kantenGruppe); + svg.appendChild(knotenGruppe); - kantenIdx.forEach(function (paar) { - var von = positionen[paar[0]]; - var nach = positionen[paar[1]]; - kantenGruppe.appendChild( - svgEl("line", { x1: von.x, y1: von.y, x2: nach.x, y2: nach.y, class: "graph-kante" }), - ); + var kantenEls = kantenIdx.map(function (paar) { + var linie = svgEl("line", { class: "graph-kante" }); + kantenGruppe.appendChild(linie); + return linie; }); - knoten.forEach(function (k, i) { - var pos = positionen[i]; - var radius = 6 + Math.min(14, Math.sqrt(k.degree || 0) * 3); - + var knotenEls = knoten.map(function (k, i) { var gruppe = svgEl("g", { class: "graph-knoten-gruppe" + (k.exists ? "" : " graph-knoten-fehlend"), - transform: "translate(" + pos.x.toFixed(1) + "," + pos.y.toFixed(1) + ")", + "data-index": i, }); + // zeigt der Browser als Tooltip — so ist der volle Name + // auch dann lesbar, wenn die Beschriftung gerade ausgeblendet ist. var titelEl = document.createElementNS(SVG_NS, "title"); titelEl.textContent = k.title; gruppe.appendChild(titelEl); - gruppe.appendChild(svgEl("circle", { r: radius, class: "graph-punkt" })); + gruppe.appendChild(svgEl("circle", { r: radien[i], class: "graph-punkt" })); - var text = svgEl("text", { x: radius + 4, y: 4, class: "graph-beschriftung" }); + var text = svgEl("text", { x: radien[i] + TEXTABSTAND, y: 0, class: "graph-beschriftung" }); text.textContent = kuerze(k.title, 28); gruppe.appendChild(text); @@ -121,9 +169,243 @@ } knotenGruppe.appendChild(gruppe); + return { gruppe: gruppe, text: text }; }); - aktiviereZoomUndPan(svg, viewport, breite, hoehe, basisUrl); + // Die Textbreite einmal messen, solange alles sichtbar ist (ein + // ausgeblendetes Element hat keine messbare Breite). Scheitert die + // Messung, hilft eine Schätzung über die Zeichenzahl. + var textBreiten = knotenEls.map(function (el) { + var gemessen = 0; + try { + gemessen = el.text.getComputedTextLength(); + } catch (e) { + gemessen = 0; + } + return gemessen || el.text.textContent.length * 6.5; + }); + + // ansicht beschreibt, wie Layout-Koordinaten (aus simuliere) auf den + // Bildschirm kommen: bildschirm = layout * massstab + verschiebung. + var ansicht = { massstab: 1, x: 0, y: 0 }; + var fokus = -1; + var bildschirm = positionen.map(function () { + return { x: 0, y: 0 }; + }); + + // aktualisiere setzt alle Elemente an ihre Bildschirmposition und + // wählt die Beschriftungen neu aus. Wird über requestAnimationFrame + // höchstens einmal je Bildschirmbild ausgeführt, auch wenn Mausrad + // oder Ziehen viel häufiger Ereignisse liefern. + var geplant = false; + function aktualisiereBald() { + if (geplant) { + return; + } + geplant = true; + window.requestAnimationFrame(function () { + geplant = false; + aktualisiere(); + }); + } + + function aktualisiere() { + positionen.forEach(function (pos, i) { + var bx = pos.x * ansicht.massstab + ansicht.x; + var by = pos.y * ansicht.massstab + ansicht.y; + bildschirm[i].x = bx; + bildschirm[i].y = by; + knotenEls[i].gruppe.setAttribute("transform", "translate(" + bx.toFixed(1) + "," + by.toFixed(1) + ")"); + }); + kantenIdx.forEach(function (paar, i) { + var von = bildschirm[paar[0]]; + var nach = bildschirm[paar[1]]; + kantenEls[i].setAttribute("x1", von.x.toFixed(1)); + kantenEls[i].setAttribute("y1", von.y.toFixed(1)); + kantenEls[i].setAttribute("x2", nach.x.toFixed(1)); + kantenEls[i].setAttribute("y2", nach.y.toFixed(1)); + }); + beschrifte(); + } + + // beschrifte entscheidet, welche Knoten gerade einen Namen tragen. + // + // Gierig in Rangfolge: Jede Beschriftung wird zuerst rechts, dann + // links vom Punkt versucht. Überdeckt sie an beiden Stellen eine + // schon gesetzte Beschriftung oder einen fremden Punkt, bleibt sie + // weg. Das ist nicht die bestmögliche Verteilung, aber es garantiert + // das Wichtigste: Keine zwei Namen liegen übereinander. + // + // Aufwand: je Knoten ein Vergleich mit allen Punkten und allen schon + // gesetzten Namen — bei 150 Knoten einige zehntausend Rechtecktests, + // was auch beim flüssigen Zoomen nicht ins Gewicht fällt. + function beschrifte() { + var punktKaesten = bildschirm.map(function (p, i) { + var r = radien[i]; + return { x1: p.x - r, y1: p.y - r, x2: p.x + r, y2: p.y + r }; + }); + var gesetzt = []; + + var reihenfolge = rangfolge; + var erlaubt = null; + if (fokus >= 0) { + // Im Fokus: nur der Knoten selbst und seine Nachbarn, er + // selbst zuerst. + erlaubt = {}; + erlaubt[fokus] = true; + nachbarn[fokus].forEach(function (n) { + erlaubt[n] = true; + }); + reihenfolge = [fokus].concat( + rangfolge.filter(function (i) { + return i !== fokus && erlaubt[i]; + }), + ); + } + + var zeigen = {}; + reihenfolge.forEach(function (i) { + var p = bildschirm[i]; + // Knoten außerhalb des sichtbaren Bereichs brauchen keinen + // Namen und sollen auch keinen Platz belegen. + if (p.x < -50 || p.y < -50 || p.x > breite + 50 || p.y > hoehe + 50) { + return; + } + var r = radien[i]; + var w = textBreiten[i]; + var y1 = p.y - ZEILENHOEHE / 2; + var y2 = p.y + ZEILENHOEHE / 2; + var rechts = { x1: p.x + r + TEXTABSTAND, y1: y1, x2: p.x + r + TEXTABSTAND + w, y2: y2 }; + var links = { x1: p.x - r - TEXTABSTAND - w, y1: y1, x2: p.x - r - TEXTABSTAND, y2: y2 }; + + // Der hervorgehobene Knoten bekommt seinen Namen immer. + var immer = i === fokus; + if (immer || istFrei(rechts, i, punktKaesten, gesetzt)) { + gesetzt.push(rechts); + zeigen[i] = "rechts"; + } else if (istFrei(links, i, punktKaesten, gesetzt)) { + gesetzt.push(links); + zeigen[i] = "links"; + } + }); + + knotenEls.forEach(function (el, i) { + var seite = zeigen[i]; + el.text.classList.toggle("graph-beschriftung-aus", !seite); + if (seite === "links") { + el.text.setAttribute("x", -(radien[i] + TEXTABSTAND)); + el.text.setAttribute("text-anchor", "end"); + } else if (seite === "rechts") { + el.text.setAttribute("x", radien[i] + TEXTABSTAND); + el.text.removeAttribute("text-anchor"); + } + }); + } + + // setzeFokus hebt Knoten i und seine Nachbarn hervor; -1 hebt die + // Hervorhebung auf. + function setzeFokus(i) { + if (i === fokus) { + return; + } + fokus = i; + svg.classList.toggle("graph-fokus", fokus >= 0); + var nah = {}; + if (fokus >= 0) { + nah[fokus] = true; + nachbarn[fokus].forEach(function (n) { + nah[n] = true; + }); + } + knotenEls.forEach(function (el, k) { + el.gruppe.classList.toggle("graph-nachbar", !!nah[k]); + }); + kantenIdx.forEach(function (paar, k) { + kantenEls[k].classList.toggle("graph-kante-aktiv", paar[0] === fokus || paar[1] === fokus); + }); + // Knoten im Fokus ans Ende ihrer Gruppe hängen: In SVG liegt + // oben, was zuletzt kommt — so verdeckt kein blasser Nachbar den + // hervorgehobenen Namen. + if (fokus >= 0) { + knotenGruppe.appendChild(knotenEls[fokus].gruppe); + } + beschrifte(); + } + + svg.addEventListener("pointerover", function (event) { + // Während des Ziehens nicht umschalten — sonst flackert die + // Hervorhebung über alle Knoten, die unter der Maus durchlaufen. + if (svg.classList.contains("graph-zieht")) { + return; + } + var gruppe = event.target.closest(".graph-knoten-gruppe"); + setzeFokus(gruppe ? Number(gruppe.getAttribute("data-index")) : -1); + }); + svg.addEventListener("pointerleave", function () { + setzeFokus(-1); + }); + + // Einpassen: den ganzen Graphen mittig in die Fläche zoomen. + var grenzen = { min: 0.1, max: 4 }; + function einpassen() { + var minX = Infinity; + var minY = Infinity; + var maxX = -Infinity; + var maxY = -Infinity; + positionen.forEach(function (p) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + }); + // Rand in Bildschirmpixeln: Platz für die Punkte selbst und für + // Beschriftungen am äußeren Rand. + var rand = 48; + var spanneX = Math.max(maxX - minX, 1); + var spanneY = Math.max(maxY - minY, 1); + var massstab = Math.min((breite - 2 * rand) / spanneX, (hoehe - 2 * rand) / spanneY); + // Kleine Graphen nicht künstlich aufblasen. + massstab = Math.min(massstab, 1.5); + + ansicht.massstab = massstab; + ansicht.x = breite / 2 - ((minX + maxX) / 2) * massstab; + ansicht.y = hoehe / 2 - ((minY + maxY) / 2) * massstab; + + // Heraus nur bis zur halben Einpass-Größe, hinein weit genug, + // dass auch dichte Knäuel auseinanderfallen. + grenzen.min = massstab * 0.5; + grenzen.max = Math.max(massstab * 12, 3); + aktualisiereBald(); + } + + einpassen(); + aktiviereZoomUndPan(svg, ansicht, grenzen, breite, hoehe, basisUrl, aktualisiereBald); + + document.querySelectorAll("[data-graph-einpassen]").forEach(function (knopf) { + knopf.addEventListener("click", einpassen); + }); + } + + // istFrei prüft, ob das Rechteck kasten weder einen fremden Punkt (alle + // außer dem eigenen, Index eigener) noch eine schon gesetzte + // Beschriftung überdeckt. + function istFrei(kasten, eigener, punktKaesten, gesetzt) { + for (var i = 0; i < punktKaesten.length; i++) { + if (i !== eigener && ueberlappt(kasten, punktKaesten[i])) { + return false; + } + } + for (var j = 0; j < gesetzt.length; j++) { + if (ueberlappt(kasten, gesetzt[j])) { + return false; + } + } + return true; + } + + // Zwei Rechtecke überlappen, wenn sie sich auf beiden Achsen überlappen. + function ueberlappt(a, b) { + return a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1; } // fnv1a bildet aus einer Zeichenkette einen 32-Bit-Hash — hier verwendet, @@ -158,10 +440,16 @@ // Klassische Kräftesimulation: Abstoßung zwischen allen Knotenpaaren // (Coulomb-artig), Anziehung entlang der Kanten (Federgesetz) und eine - // schwache Kraft zur Bildmitte, damit der Graph nicht davonwandert. + // schwache Kraft zur Mitte, damit der Graph nicht davonwandert. Dazu + // eine Kollisionskraft, die Punkte, die sich überlappen würden, hart + // auseinanderschiebt. + // + // Das Ergebnis sind Layout-Koordinaten um den Nullpunkt, unabhängig von + // der Größe der Zeichenfläche — auf den Bildschirm bringt sie erst + // einpassen() in zeichne(). // // O(Knoten²) je Durchlauf — bei der serverseitigen Obergrenze von 150 - // Knoten sind das rund 22.000 Paare, mit 250 Durchläufen im + // Knoten sind das rund 11.000 Paare, mit 300 Durchläufen im // einstelligen Millionenbereich an Rechenschritten. Das läuft im Browser // in Bruchteilen einer Sekunde. // @@ -170,25 +458,31 @@ // Startpositionen und damit, weil die Kräfte selbst keine Zufallszahlen // mehr ziehen, dasselbe Endlayout. Ändert sich die Knotenmenge, ändert // sich auch der Seed, und das Layout darf sich neu einpendeln. - function simuliere(n, kantenIdx, breite, hoehe, slugsFuerSeed) { + function simuliere(n, kantenIdx, radien, slugsFuerSeed) { var zufall = mulberry32(fnv1a(slugsFuerSeed.join("\n"))); + var ABSTOSSUNG = 3000; + var FEDER = 0.02; + var ZIELLAENGE = 110; + var DAEMPFUNG = 0.85; + var ITERATIONEN = 300; + // Mindestabstand zwischen zwei Punkten zusätzlich zu ihren Radien. + var LUFT = 24; + + // Startfläche wächst mit der Wurzel der Knotenzahl — so beginnt ein + // großer Graph nicht zusammengequetscht auf derselben Fläche wie ein + // kleiner. + var spanne = ZIELLAENGE * Math.sqrt(n); var positionen = []; for (var i = 0; i < n; i++) { positionen.push({ - x: breite / 2 + (zufall() - 0.5) * breite * 0.6, - y: hoehe / 2 + (zufall() - 0.5) * hoehe * 0.6, + x: (zufall() - 0.5) * spanne, + y: (zufall() - 0.5) * spanne, vx: 0, vy: 0, }); } - var ABSTOSSUNG = 1800; - var FEDER = 0.02; - var ZIELLAENGE = 90; - var DAEMPFUNG = 0.85; - var ITERATIONEN = 250; - for (var iter = 0; iter < ITERATIONEN; iter++) { for (var a = 0; a < n; a++) { for (var b = a + 1; b < n; b++) { @@ -197,6 +491,10 @@ var distQ = dx * dx + dy * dy || 0.01; var dist = Math.sqrt(distQ); var kraft = ABSTOSSUNG / distQ; + var mindest = radien[a] + radien[b] + LUFT; + if (dist < mindest) { + kraft += (mindest - dist) * 0.5; + } var fx = (dx / dist) * kraft; var fy = (dy / dist) * kraft; positionen[a].vx += fx; @@ -222,8 +520,8 @@ }); for (var k = 0; k < n; k++) { - positionen[k].vx += (breite / 2 - positionen[k].x) * 0.002; - positionen[k].vy += (hoehe / 2 - positionen[k].y) * 0.002; + positionen[k].vx -= positionen[k].x * 0.002; + positionen[k].vy -= positionen[k].y * 0.002; } for (var m = 0; m < n; m++) { positionen[m].vx *= DAEMPFUNG; @@ -236,22 +534,21 @@ return positionen; } - // Zoom (Mausrad) und Pan (Ziehen) auf der Transform-Gruppe "viewport". + // Zoom (Mausrad) und Pan (Ziehen). Verändert nur das Objekt ansicht + // (massstab, x, y) und ruft danach beiAenderung auf — das Umsetzen auf + // die SVG-Elemente übernimmt zeichne(). // // Der naheliegende Ansatz — Mausbewegung in Bildschirm-Pixeln direkt als // Verschiebung im viewBox-Koordinatensystem übernehmen — geht nur auf, // wenn die dargestellte Größe der SVG-Fläche exakt ihrer viewBox - // entspricht. Das stimmt zufällig beim ersten Zeichnen (viewBox-Breite - // kommt von svg.clientWidth), aber nicht mehr nach einer + // entspricht. Das stimmt zufällig beim ersten Zeichnen (viewBox-Größe + // kommt von svg.clientWidth/clientHeight), aber nicht mehr nach einer // Fenstergröße-Änderung (die SVG-Breite ist "100%" und wächst/schrumpft // mit), und auch sonst nicht zuverlässig (abweichende rem-Basis, // Browser-Zoom). zuViewBox() rechnet deshalb bei jeder Bewegung über // getBoundingClientRect() neu um, statt sich auf diese Übereinstimmung // zu verlassen. - function aktiviereZoomUndPan(svg, viewport, breite, hoehe, basisUrl) { - var massstab = 1; - var x = 0; - var y = 0; + function aktiviereZoomUndPan(svg, ansicht, grenzen, breite, hoehe, basisUrl, beiAenderung) { var ziehtGerade = false; var startPunkt = { x: 0, y: 0 }; var startX = 0; @@ -262,10 +559,6 @@ // Ziehen statt als Klick. var ZIEHSCHWELLE = 6; - function anwenden() { - viewport.setAttribute("transform", "translate(" + x + "," + y + ") scale(" + massstab + ")"); - } - function zuViewBox(clientX, clientY) { var rect = svg.getBoundingClientRect(); return { @@ -282,8 +575,8 @@ bewegungsstrecke = 0; gedruecktesElement = event.target.closest(".graph-klickbar"); startPunkt = zuViewBox(event.clientX, event.clientY); - startX = x; - startY = y; + startX = ansicht.x; + startY = ansicht.y; svg.classList.add("graph-zieht"); svg.setPointerCapture(event.pointerId); }); @@ -293,11 +586,10 @@ return; } var jetzt = zuViewBox(event.clientX, event.clientY); - // Die Verschiebung (translate) der Gruppe lebt im selben - // Koordinatensystem wie die viewBox der äußeren SVG — deshalb - // hier ohne weitere Umrechnung durch massstab: Eine Bewegung um - // 10 viewBox-Einheiten verschiebt den Inhalt um genau 10 - // Einheiten, unabhängig vom aktuellen Zoomstand. + // Die Verschiebung lebt im Bildschirm-Koordinatensystem der + // viewBox — deshalb hier ohne weitere Umrechnung durch massstab: + // Eine Bewegung um 10 viewBox-Einheiten verschiebt den Inhalt um + // genau 10 Einheiten, unabhängig vom aktuellen Zoomstand. // // Der größte je erreichte Abstand zum Startpunkt zählt, nicht // die Summe aller Zwischenschritte — sonst würde allein die @@ -306,9 +598,9 @@ // einen stillstehenden Klick als Ziehen werten. var abstand = Math.abs(jetzt.x - startPunkt.x) + Math.abs(jetzt.y - startPunkt.y); bewegungsstrecke = Math.max(bewegungsstrecke, abstand); - x = startX + (jetzt.x - startPunkt.x); - y = startY + (jetzt.y - startPunkt.y); - anwenden(); + ansicht.x = startX + (jetzt.x - startPunkt.x); + ansicht.y = startY + (jetzt.y - startPunkt.y); + beiAenderung(); }); function beendeZiehen(event) { @@ -344,20 +636,20 @@ "wheel", function (event) { event.preventDefault(); - var faktor = event.deltaY < 0 ? 1.1 : 0.9; - var neuerMassstab = Math.min(4, Math.max(0.25, massstab * faktor)); - if (neuerMassstab === massstab) { + var faktor = event.deltaY < 0 ? 1.15 : 1 / 1.15; + var neuerMassstab = Math.min(grenzen.max, Math.max(grenzen.min, ansicht.massstab * faktor)); + if (neuerMassstab === ansicht.massstab) { return; } // Zoomt zur Cursor-Position statt zur Bildecke: Der Punkt // unter dem Mauszeiger bleibt an derselben Stelle stehen, // statt dass der Inhalt beim Zoomen "wegläuft". var zeiger = zuViewBox(event.clientX, event.clientY); - var verhaeltnis = neuerMassstab / massstab; - x = zeiger.x - (zeiger.x - x) * verhaeltnis; - y = zeiger.y - (zeiger.y - y) * verhaeltnis; - massstab = neuerMassstab; - anwenden(); + var verhaeltnis = neuerMassstab / ansicht.massstab; + ansicht.x = zeiger.x - (zeiger.x - ansicht.x) * verhaeltnis; + ansicht.y = zeiger.y - (zeiger.y - ansicht.y) * verhaeltnis; + ansicht.massstab = neuerMassstab; + beiAenderung(); }, { passive: false }, ); diff --git a/static/style.css b/static/style.css index 04b81b8..137d8c3 100644 --- a/static/style.css +++ b/static/style.css @@ -59,7 +59,7 @@ /* Breiten. Die Seite nutzt den Platz aus; nur der Fließtext begrenzt sich * selbst (siehe Regel zwei oben). */ - --breite-seite: 92rem; /* Seitenleiste plus Inhalt */ + --breite-seite: 120rem; /* Seitenleiste plus Inhalt */ --breite-leiste: 15rem; /* Seitenleiste */ --breite-lesen: 44rem; /* Fließtext eines Eintrags */ --breite-schmal: 25rem; /* Anmeldung */ @@ -198,7 +198,9 @@ pre code { background: none; border: none; padding: 0; } font-size: var(--t-meta); font-weight: 500; } -.arbeitsbereich:hover { +/* Nur der Umschalter (Link) reagiert auf die Maus — beim Workspace-Admin + * steht der Name als reiner Text ohne Ziel. */ +a.arbeitsbereich:hover { background: var(--flaeche-leis); color: var(--text); text-decoration: none; @@ -214,7 +216,7 @@ pre code { background: none; border: none; padding: 0; } padding-inline: var(--s5); display: grid; grid-template-columns: var(--breite-leiste) minmax(0, 1fr); - gap: var(--s7); + gap: var(--s6); align-items: start; } @@ -635,13 +637,17 @@ table.tabelle { display: flex; align-items: center; justify-content: center; - padding: var(--s5); + /* Unten mehr Abstand als oben: Das hebt den Block etwas über die + * mathematische Mitte. Genau mittig wirkt er optisch zu tief. */ + padding: var(--s5) var(--s5) 12vh; } .anmeldeseite .karte { width: 100%; padding: var(--s6); } .anmeldeseite .hilfe { display: block; margin-bottom: var(--s5); } /* Logo zentriert über der Anmeldekarte. */ -.anmelde-logo { display: flex; justify-content: center; margin-bottom: var(--s4); } -.anmelde-logo img { width: 160px; height: auto; } +.anmelde-logo { display: flex; justify-content: center; margin-bottom: var(--s2); } +/* 264px, damit auch die kleine Unterzeile im Logo ("MCP Server • Wiki / + * Knowledge Base") lesbar bleibt. max-width fängt schmale Handys ab. */ +.anmelde-logo img { width: 264px; max-width: 100%; height: auto; } /* Werkzeugleiste über der Eintragsliste: Suche und Filter. */ .werkzeugleiste { @@ -848,13 +854,16 @@ table.tabelle { .graph-huelle svg { display: block; width: 100%; - height: 35rem; + /* Möglichst viel Fläche, aber auf kleinen und sehr hohen Bildschirmen + * in vernünftigen Grenzen. */ + height: clamp(30rem, 75vh, 60rem); cursor: grab; touch-action: none; } .graph-huelle svg.graph-zieht { cursor: grabbing; } -.graph-kante { stroke: var(--rahmen-stark); stroke-width: 1.5; } +.graph-kante { stroke: var(--rahmen-stark); stroke-width: 1.5; transition: opacity 0.15s; } +.graph-knoten-gruppe { transition: opacity 0.15s; } .graph-punkt { fill: var(--akzent-flach); @@ -873,12 +882,55 @@ table.tabelle { /* Beschriftungen sollen das Ziehen des Graphen nicht durch * Textmarkierung stören. */ user-select: none; + /* Ein Rand in Hintergrundfarbe um jeden Buchstaben: Kanten, die hinter + * dem Text verlaufen, werden so unterbrochen, statt die Schrift zu + * durchkreuzen. paint-order malt den Rand zuerst, die Füllung darüber. */ + paint-order: stroke; + stroke: var(--flaeche); + stroke-width: 3px; + stroke-linejoin: round; } +/* Welche Namen Platz haben, entscheidet static/graph.js (beschrifte()). */ +.graph-beschriftung-aus { display: none; } .graph-knoten-fehlend .graph-beschriftung { fill: var(--text-leise); } .graph-klickbar { cursor: pointer; } .graph-klickbar:hover .graph-punkt { stroke-width: 2.5; } .graph-klickbar:hover .graph-beschriftung { fill: var(--akzent-text); } +/* Hervorhebung beim Überfahren: der Knoten, seine Nachbarn und die + * verbindenden Kanten bleiben kräftig, der Rest tritt zurück. */ +.graph-fokus .graph-knoten-gruppe:not(.graph-nachbar) { opacity: 0.2; } +.graph-fokus .graph-kante:not(.graph-kante-aktiv) { opacity: 0.1; } +.graph-kante-aktiv { stroke: var(--akzent); } + +/* Seite "Alle Schlagworte": mehrspaltige Liste, die Spaltenzahl ergibt sich + * aus der verfügbaren Breite. */ +.schlagwort-liste { + list-style: none; + margin: 0 0 var(--s5); + padding: 0; + columns: 14rem; + column-gap: var(--s5); +} +.schlagwort-liste li { break-inside: avoid; } +.schlagwort-liste a { + display: flex; + align-items: center; + gap: var(--s2); + padding: var(--s1) var(--s2); + border-radius: var(--radius); + color: var(--text); + font-size: var(--t-meta); +} +.schlagwort-liste a:hover { background: var(--flaeche-leis); text-decoration: none; } +.schlagwort-gruppe h2 { + font-size: var(--t-gross); + color: var(--text-leise); + border-bottom: 1px solid var(--rahmen); + padding-bottom: var(--s1); + margin-bottom: var(--s2); +} + /* Gefahrenzone: Löschvorgänge stehen abgesetzt am Seitenende, nicht zwischen * den harmlosen Aktionen. Vorher steckte das Löschen eines Projekts samt * Bestätigungsfeld in einer Tabellenzelle — zu eng und viel zu beiläufig für diff --git a/static/tagfilter.js b/static/tagfilter.js new file mode 100644 index 0000000..de99898 --- /dev/null +++ b/static/tagfilter.js @@ -0,0 +1,67 @@ +// Filtert die Liste auf der Seite "Alle Schlagworte" (templates/tags.html) +// schon beim Tippen, ohne die Seite neu zu laden. +// +// Gesucht wird nach Teilwörtern ("deploy" findet auch "k8s-deployment"), +// Groß-/Kleinschreibung und Umlaute sind egal. Buchstabengruppen, in denen +// nichts mehr übrig bleibt, verschwinden mit. +(function () { + "use strict"; + + var feld = document.querySelector("[data-tag-filter]"); + if (!feld) { + return; + } + var eintraege = document.querySelectorAll("[data-tag-liste] [data-tag]"); + var gruppen = document.querySelectorAll("[data-tag-gruppe]"); + var leer = document.querySelector("[data-tag-leer]"); + + // vereinfache bringt Suchbegriff und Schlagwort in dieselbe Form: + // klein geschrieben und ohne Umlaute, damit "uber" auch "übersicht" findet. + function vereinfache(text) { + return text + .toLowerCase() + .replace(/ä/g, "a") + .replace(/ö/g, "o") + .replace(/ü/g, "u") + .replace(/ß/g, "ss"); + } + + function filtere() { + var begriff = vereinfache(feld.value.trim()); + var sichtbar = 0; + + eintraege.forEach(function (li) { + var passt = vereinfache(li.getAttribute("data-tag")).indexOf(begriff) !== -1; + li.hidden = !passt; + if (passt) { + sichtbar++; + } + }); + + gruppen.forEach(function (gruppe) { + gruppe.hidden = !gruppe.querySelector("[data-tag]:not([hidden])"); + }); + + if (leer) { + leer.hidden = sichtbar > 0; + } + } + + feld.addEventListener("input", filtere); + + // Enter bei genau einem Treffer öffnet ihn direkt — spart den Griff zur Maus. + feld.addEventListener("keydown", function (event) { + if (event.key !== "Enter") { + return; + } + var treffer = document.querySelectorAll("[data-tag-liste] [data-tag]:not([hidden]) a"); + if (treffer.length === 1) { + event.preventDefault(); + window.location.href = treffer[0].href; + } + }); + + // Der Browser stellt beim Zurück-Navigieren den Feldinhalt wieder her — + // die Liste muss dann dazu passen. + filtere(); +})(); diff --git a/templates/entries.html b/templates/entries.html index 2b225a0..ff86480 100644 --- a/templates/entries.html +++ b/templates/entries.html @@ -7,9 +7,17 @@ {{if .Project.Description}}{{.Project.Description}}{{else}}Wissen dieses Projekts — von Menschen und Agenten.{{end}} </span> </div> - {{if .Session.CanWrite}} - <div class="seitenkopf-aktionen"> + {{if or .Session.CanWrite .Session.CanDownloadProject}} + <div class="seitenkopf-aktionen reihe"> + {{/* Derselbe Platz wie "Alle als ZIP herunterladen" unter Projekte. + download: Der Browser speichert das ZIP, statt die Seite zu wechseln. + Wer das darf, steht in Session.CanDownloadProject. */}} + {{if .Session.CanDownloadProject}} + <a class="knopf zweitrangig" href="/p/{{.Project.Slug}}/export.zip" download>Als ZIP herunterladen</a> + {{end}} + {{if .Session.CanWrite}} <a class="knopf" href="/p/{{.Project.Slug}}/new">Neuer Eintrag</a> + {{end}} </div> {{end}} </div> diff --git a/templates/graph.html b/templates/graph.html index 659c6ee..6ad5381 100644 --- a/templates/graph.html +++ b/templates/graph.html @@ -6,6 +6,9 @@ <span class="hilfe">Verweise zwischen den Einträgen dieses Projekts. Gestrichelte Knoten sind Ziele eines <code>[[…]]</code>-Verweises, zu denen es noch keinen Eintrag gibt.</span> </div> + <div class="seitenkopf-aktionen"> + <button type="button" class="zweitrangig" data-graph-einpassen>Ganzen Graphen zeigen</button> + </div> </div> <p class="hilfe" data-graph-hinweis hidden></p> @@ -15,6 +18,7 @@ role="img" aria-label="Verweisgraph"></svg> </div> -<p class="hilfe">Ziehen zum Verschieben, Mausrad zum Zoomen, Klick auf einen Knoten öffnet den Eintrag.</p> +<p class="hilfe">Ziehen zum Verschieben, Mausrad zum Zoomen — beim Hineinzoomen erscheinen weitere Namen. + Zeigt die Maus auf einen Knoten, treten er und seine Nachbarn hervor. Ein Klick öffnet den Eintrag.</p> {{template "fuss" .}} diff --git a/templates/login.html b/templates/login.html index da128d5..e218233 100644 --- a/templates/login.html +++ b/templates/login.html @@ -9,7 +9,7 @@ <picture> wählt anhand der Systemeinstellung die passende aus. */}} <picture class="anmelde-logo"> <source srcset="/static/img/munin_logo_dark.svg" media="(prefers-color-scheme: dark)"> - <img src="/static/img/munin_logo_transparent.svg" alt="Munin" width="160" height="160"> + <img src="/static/img/munin_logo_transparent.svg" alt="Munin" width="264" height="264"> </picture> <div class="karte"> <span class="hilfe">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin diff --git a/templates/partials.html b/templates/partials.html index 9ef65d9..f6deeb5 100644 --- a/templates/partials.html +++ b/templates/partials.html @@ -26,6 +26,7 @@ <script src="/static/preview.js"></script> <script src="/static/autocomplete.js"></script> <script src="/static/graph.js"></script> +<script src="/static/tagfilter.js"></script> </body> </html> {{end}} @@ -44,12 +45,19 @@ <div class="kopfleiste-innen"> <div class="reihe"> <a class="marke-wort" href="/">Munin</a> - {{if .Session.WorkspaceName}} + {{if .Session.IsPlatformAdmin}}{{if .Session.WorkspaceName}} <a class="arbeitsbereich" href="/admin/workspaces"> <span class="arbeitsbereich-trenner" aria-hidden="true">/</span> <span class="arbeitsbereich-name">{{.Session.WorkspaceName}}</span> <span class="arbeitsbereich-pfeil" aria-hidden="true">⌄</span> </a> + {{end}}{{else if .Session.IsWorkspaceAdmin}} + {{/* Der Workspace-Admin hat genau einen Workspace — der Name steht + zur Orientierung da, ohne Umschalter. */}} + <span class="arbeitsbereich"> + <span class="arbeitsbereich-trenner" aria-hidden="true">/</span> + <span class="arbeitsbereich-name">{{.Session.WorkspaceName}}</span> + </span> {{end}} </div> <form method="POST" action="/logout" class="reihe"> @@ -66,7 +74,7 @@ über der Liste — sie sind Navigation, und die Liste bekommt so mehr Platz. */}} <aside class="seitenleiste"> - {{if .Session.IsPlatformAdmin}} + {{if .Session.CanManageProjects}} <nav class="nav-gruppe"> <h2 class="nav-titel">Projekte</h2> <ul class="nav-liste"> @@ -92,6 +100,8 @@ <li><a href="/p/{{.Project.Slug}}/graph" {{if eq .Bereich "graph"}}class="aktiv"{{end}}>Graph</a></li> {{if .Session.CanManageKeys}} <li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Bereich "zugaenge"}}class="aktiv"{{end}}>Zugänge</a></li> + {{end}} + {{if .Session.CanImport}} <li><a href="/p/{{.Project.Slug}}/import" {{if eq .Bereich "import"}}class="aktiv"{{end}}>Import</a></li> {{end}} <li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Bereich "nutzung"}}class="aktiv"{{end}}>Nutzung</a></li> @@ -116,6 +126,17 @@ </li> {{end}} </ul> + {{/* Die Seitenleiste zeigt nur die häufigsten Schlagworte (navTagLimit + in internal/web/server.go). Gibt es mehr, führt dieser Link zur + vollständigen, filterbaren Liste. */}} + {{if gt .Nav.TagsGesamt (len .Nav.Tags)}} + <ul class="nav-liste leise"> + <li><a href="/p/{{.Project.Slug}}/tags" {{if eq .Bereich "schlagworte"}}class="aktiv"{{end}}> + <span class="nav-text">Alle Schlagworte…</span> + <span class="nav-zahl">{{.Nav.TagsGesamt}}</span> + </a></li> + </ul> + {{end}} </nav> {{end}} {{end}} @@ -128,6 +149,13 @@ <li><a href="/admin/workspaces" {{if eq .Bereich "workspaces"}}class="aktiv"{{end}}>Workspaces</a></li> </ul> </nav> + {{else if .Session.IsWorkspaceAdmin}} + <nav class="nav-gruppe"> + <h2 class="nav-titel">Workspace</h2> + <ul class="nav-liste"> + <li><a href="/admin/audit">Protokoll (alle)</a></li> + </ul> + </nav> {{end}} {{/* Die Hilfe steht für alle ganz unten — gleich, mit welcher Rolle diff --git a/templates/project-edit.html b/templates/project-edit.html index d13840f..af65729 100644 --- a/templates/project-edit.html +++ b/templates/project-edit.html @@ -32,6 +32,10 @@ <input type="text" id="slug" value="{{.Data.Item.Project.Slug}}" disabled> </div> + {{/* Das Schreibbudget ändert nur der Plattform-Admin. Der Server + ignoriert das Feld für alle anderen ohnehin (handleProjectEditSave) + — hier wird es nur deshalb nicht angeboten. */}} + {{if .Session.CanEditWriteBudget}} <div class="feld kurz"> <label for="write_limit_hourly">Schreibbudget je Stunde</label> <span class="hilfe"> @@ -41,6 +45,15 @@ <input type="number" id="write_limit_hourly" name="write_limit_hourly" min="0" step="1" value="{{.Data.Item.EigenesLimit}}"> </div> + {{else}} + <div class="feld kurz"> + <label>Schreibbudget je Stunde</label> + <span class="hilfe"> + {{if .Data.Item.Limit}}{{.Data.Item.Limit}}, im laufenden Fenster noch frei: {{.Data.Item.Frei}}.{{else}}unbegrenzt.{{end}} + Ändern kann es nur der Plattform-Admin. + </span> + </div> + {{end}} <div class="formular-aktionen"> <button type="submit">Speichern</button> diff --git a/templates/projects.html b/templates/projects.html index 33d7810..1b0577f 100644 --- a/templates/projects.html +++ b/templates/projects.html @@ -2,20 +2,26 @@ <div class="seitenkopf"> <div class="seitenkopf-titel"> - <h1>Projekte {{template "hilfelink" "plattform-admin#projekte"}}</h1> + <h1>Projekte {{if .Session.IsPlatformAdmin}}{{template "hilfelink" "plattform-admin#projekte"}}{{else}}{{template "hilfelink" "zugaenge#workspace-admin"}}{{end}}</h1> <span class="hilfe">Jedes Projekt hat eigene Einträge, eigene Zugänge und einen eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht.</span> </div> - {{if .ExportEnabled}} - <div class="seitenkopf-aktionen"> - {{/* Der Export läuft ohnehin täglich. Der Knopf ist für den Fall, dass - man unmittelbar vor einer Sicherung einen aktuellen Stand braucht. */}} + <div class="seitenkopf-aktionen reihe"> + {{/* Der Download geht an jeden, der diese Seite sieht (Plattform- und + Workspace-Admin), und braucht kein EXPORT_DIR. */}} + <a href="/admin/export.zip" class="knopf zweitrangig" download>Alle als ZIP herunterladen</a> + + {{/* Der Export auf die Platte des Servers ist Sache des + Plattform-Admins, nicht des Workspace-Admins. Er läuft ohnehin + täglich. Der Knopf ist für den Fall, dass man unmittelbar vor einer + Sicherung einen aktuellen Stand braucht. */}} + {{if and .ExportEnabled .Session.IsPlatformAdmin}} <form method="POST" action="/admin/export" class="reihe"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <button type="submit" class="zweitrangig">Jetzt exportieren</button> </form> + {{end}} </div> - {{end}} </div> {{if .Data.Projects}} diff --git a/templates/tags.html b/templates/tags.html new file mode 100644 index 0000000..bc2b2fc --- /dev/null +++ b/templates/tags.html @@ -0,0 +1,60 @@ +{{template "kopf" .}} + +{{$slug := .Project.Slug}} + +<div class="seitenkopf"> + <div class="seitenkopf-titel"> + <h1>Schlagworte {{template "hilfelink" "weboberflaeche#finden"}}</h1> + <span class="hilfe">Alle Schlagworte dieses Projekts. Ein Klick filtert die Eintragsliste.</span> + </div> +</div> + +{{if .Data.Tags}} +{{/* Das Filterfeld filtert ohne Neuladen (static/tagfilter.js). Ohne + JavaScript bleibt es wirkungslos, die vollständige Liste steht dann + trotzdem darunter. */}} +<div class="werkzeugleiste"> + <input type="search" placeholder="Schlagworte filtern…" autocomplete="off" + aria-label="Schlagworte filtern" data-tag-filter> +</div> + +<p class="hilfe"> + {{len .Data.Tags}} Schlagworte · Sortiert nach + {{if eq .Data.Sortierung "name"}} + <a href="/p/{{$slug}}/tags">Häufigkeit</a> · <strong>Name</strong> + {{else}} + <strong>Häufigkeit</strong> · <a href="/p/{{$slug}}/tags?sort=name">Name</a> + {{end}} +</p> + +<div data-tag-liste> +{{if eq .Data.Sortierung "name"}} + {{range .Data.Gruppen}} + <section class="schlagwort-gruppe" data-tag-gruppe> + <h2>{{.Buchstabe}}</h2> + <ul class="schlagwort-liste"> + {{range .Tags}} + <li data-tag="{{.Tag}}"><a href="/p/{{$slug}}/?tag={{.Tag}}"> + <span class="nav-text">{{.Tag}}</span><span class="nav-zahl">{{.Count}}</span> + </a></li> + {{end}} + </ul> + </section> + {{end}} +{{else}} + <ul class="schlagwort-liste"> + {{range .Data.Tags}} + <li data-tag="{{.Tag}}"><a href="/p/{{$slug}}/?tag={{.Tag}}"> + <span class="nav-text">{{.Tag}}</span><span class="nav-zahl">{{.Count}}</span> + </a></li> + {{end}} + </ul> +{{end}} +</div> + +<div class="leer" data-tag-leer hidden><p>Kein Schlagwort passt zum Filter.</p></div> +{{else}} +<div class="leer"><p>Noch keine Schlagworte in diesem Projekt.</p></div> +{{end}} + +{{template "fuss" .}} diff --git a/templates/workspace-edit.html b/templates/workspace-edit.html index 9781874..e7e12f4 100644 --- a/templates/workspace-edit.html +++ b/templates/workspace-edit.html @@ -26,10 +26,95 @@ </form> </div> +{{/* Workspace-Admins (AUSBAUSTUFEN.md 4.8): verwalten in diesem Workspace + Projekte und Zugänge, nur über die Weboberfläche. Anlegen und zurückziehen + kann sie nur der Plattform-Admin — deshalb stehen sie hier und nicht auf + einer Seite, die der Workspace-Admin selbst erreicht. */}} +<section class="abschnitt"> + <h2>Workspace-Admins {{template "hilfelink" "plattform-admin#workspace-admins"}}</h2> + <span class="hilfe">Ein Workspace-Admin legt in diesem Workspace Projekte an, bearbeitet und + löscht sie und verwaltet deren Zugänge. Er meldet sich mit seinem Token in der + Weboberfläche an — für MCP taugt das Token nicht. Andere Workspaces, das + Schreibbudget und der Export bleiben beim Plattform-Admin.</span> + + {{if .Data.NewToken}} + <div class="karte key-anzeige"> + <strong>Token für „{{.Data.NewTokenName}}“</strong> + <code class="key-wert">{{.Data.NewToken}}</code> + <span class="hilfe">Wird nur jetzt angezeigt — gespeichert ist nur eine Prüfsumme. + Geht das Token verloren, muss ein neues erzeugt werden.</span> + </div> + {{end}} + + {{if .Data.Ungenutzt}} + <div class="meldung fehler"> + {{if eq .Data.Ungenutzt 1}}Ein aktiver Workspace-Admin wurde{{else}}{{.Data.Ungenutzt}} aktive Workspace-Admins wurden{{end}} + seit über 90 Tagen nicht benutzt. Jeder davon ist ein offener Zugang zum ganzen + Workspace. Ist die Person nicht mehr zuständig, am besten zurückziehen. + </div> + {{end}} + + {{if .Data.Admins}} + <div class="tabelle-huelle"> + <table class="tabelle"> + <thead> + <tr> + <th>Name</th> + <th>Angelegt</th> + <th>Zuletzt benutzt</th> + <th>Status</th> + <th class="schmal rechts"></th> + </tr> + </thead> + <tbody> + {{$csrf := .CSRFToken}}{{$id := .Data.Workspace.ID}}{{$jetzt := .Data.Jetzt}} + {{range .Data.Admins}} + <tr {{if .Revoked}}class="gedaempft"{{end}}> + <td>{{.Name}}</td> + <td>{{datum .CreatedAt}}</td> + <td>{{if .LastUsedAt.IsZero}}<span class="hilfe">nie</span>{{else}}{{datum .LastUsedAt}}{{end}}</td> + <td> + {{if .Revoked}}zurückgezogen{{else}}aktiv{{end}} + {{if .Unused $jetzt}}<span class="marke warnung" title="Seit über 90 Tagen nicht benutzt">ungenutzt</span>{{end}} + </td> + <td class="schmal rechts"> + {{if not .Revoked}} + <form method="POST" action="/admin/workspaces/{{$id}}/admins/revoke" class="reihe" + data-confirm="Zugang „{{.Name}}“ zurückziehen? Eine laufende Sitzung endet sofort."> + <input type="hidden" name="csrf_token" value="{{$csrf}}"> + <input type="hidden" name="id" value="{{.ID}}"> + <button type="submit" class="warnung">Zurückziehen</button> + </form> + {{end}} + </td> + </tr> + {{end}} + </tbody> + </table> + </div> + {{end}} + + <div class="karte"> + <form method="POST" action="/admin/workspaces/{{.Data.Workspace.ID}}/admins/create" class="formular"> + <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> + + <div class="feld mittel"> + <label for="admin-name">Name</label> + <span class="hilfe">Wer den Zugang bekommt — so steht es später im Protokoll.</span> + <input type="text" id="admin-name" name="name" required placeholder="z.B. Erika Muster"> + </div> + + <div class="formular-aktionen"> + <button type="submit">Workspace-Admin anlegen</button> + </div> + </form> + </div> +</section> + <section class="abschnitt"> <h2>Gefahrenzone</h2> <span class="hilfe">Das Löschen entfernt alle {{.Data.Projects}} Projekte dieses Workspace samt - ihrer Einträge und Zugänge unwiederbringlich — die Workspace-Datei wird von der Platte + ihrer Einträge, Zugänge und Workspace-Admins unwiederbringlich — die Workspace-Datei wird von der Platte entfernt. Der Markdown-Export bleibt erhalten, sofern einer eingerichtet ist.</span> <div class="karte gefahrenzone"> diff --git a/templates/workspaces.html b/templates/workspaces.html index ac375aa..766ba02 100644 --- a/templates/workspaces.html +++ b/templates/workspaces.html @@ -15,6 +15,7 @@ <tr> <th>Workspace</th> <th class="zahl">Projekte</th> + <th class="zahl">Workspace-Admins</th> <th>Angelegt</th> <th class="schmal rechts">Auswahl</th> <th class="schmal rechts">Verwaltung</th> @@ -29,6 +30,16 @@ {{if eq .ID $aktuell}}<span class="marke betont">Aktuell</span>{{end}} </td> <td class="zahl">{{.Projects}}</td> + {{/* Aktive Workspace-Admins. "ungenutzt" ist das Signal, auf das + es hier ankommt: ein offener Zugang zum ganzen Workspace, + den seit über 90 Tagen niemand benutzt. */}} + <td class="zahl"> + {{if .Admins}}{{.Admins}}{{else}}<span class="hilfe">keine</span>{{end}} + {{if .AdminsUngenutzt}} + <div><a href="/admin/workspaces/{{.ID}}/edit" class="marke warnung" + title="Seit über 90 Tagen nicht benutzt">{{.AdminsUngenutzt}} ungenutzt</a></div> + {{end}} + </td> <td class="hilfe">{{.CreatedAt}}</td> <td class="schmal rechts"> {{if eq .ID $aktuell}}