Ausbau, Erweiterung, Graphen, Bugfixes, Export, Stabilität, Lasttest

This commit is contained in:
Thomas Adamek 2026-10-06 13:51:48 +02:00
parent c4753c5fdb
commit 71c7e9bf5b
57 changed files with 4950 additions and 329 deletions

View file

@ -73,6 +73,35 @@ zum Lesen, eine zum Schreiben.
Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100 Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100
Schreibvorgänge/s — etwa **1 % der Kapazität**. 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 **Warum aufgeschoben:** Es bräuchte trotzdem eine zentrale Datenbank, denn drei
Dinge braucht man, *bevor* man das Projekt kennt: `ResolveKeyHash` (Key → Dinge braucht man, *bevor* man das Projekt kennt: `ResolveKeyHash` (Key →
Projekt), `ListProjects` und `EnsureWorkspace`. Daraus folgt: 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`. - **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`.
- **Offen:** Stufe 2 (Ablaufdatum). - **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 Teams wird er damit zum Nadelöhr, und ein gemeinsames Token für alle
Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen. 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 anlegen, umbenennen und löschen darf, aber weder andere Workspaces sieht noch
das Schreibbudget ändert (siehe 3.8, warum das beim Plattform-Admin bleibt). 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 **Entscheidung (2026-09-30): ohne Keycloak umsetzen.** Ursprünglich stand hier,
wäre „Workspace-Admin“ einfach eine Gruppenmitgliedschaft. Ohne 4.3 bräuchte es 4.8 nur zusammen mit 4.3 zu entscheiden. Keycloak wird aktuell aber nicht
eine eigene Key-Art ohne Projektbindung. Beides zusammen entscheiden, nicht gebraucht, das Nadelöhr beim Plattform-Admin dagegen schon. Kommt 4.3 doch,
4.8 vorziehen und später umbauen. 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 ### 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 auch das öffentliche Projekt. Übrig bliebe nur „öffentlich über
Workspaces hinweg“ (Variante A oben). 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 ## Teil 5 — Oberfläche
@ -1048,8 +1174,9 @@ Als Nächstes:
`remember`-Aufrufe legen neu an, wie viele aktualisieren? `remember`-Aufrufe legen neu an, wie viele aktualisieren?
8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) — 8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) —
beide ändern den MCP-Vertrag. beide ändern den MCP-Vertrag.
9. **Admin je Workspace** (4.8) zusammen mit **Keycloak** (4.3). Wird dringend, 9. ~~**Admin je Workspace** (4.8)~~ — erledigt 2026-09-30, ohne Keycloak
sobald das Anlegen von Projekten spürbar auf den Plattform-Admin wartet. (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 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 Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es

347
BETRIEB.md Normal file
View file

@ -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/<workspace-id>.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/<zeitstempel>/` 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://<server>/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/* <BACKUP_DIR>/* <EXPORT_DIR>`
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 <BACKUP_DIR>/<zeitstempel>/workspaces/<id>.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/<id>.db-wal /data/workspaces/<id>.db-shm
# 5. Die kaputte oder falsche Datei zur Seite legen, nicht löschen
mv /data/workspaces/<id>.db /data/workspaces/<id>.db.vorher
# 6. Zurückkopieren, Besitzer richtig setzen
cp <BACKUP_DIR>/<zeitstempel>/workspaces/<id>.db /data/workspaces/
chown 10001:10001 /data/workspaces/<id>.db
# 7. Starten und prüfen
docker start munin
curl -s https://<server>/healthz
```
Danach in der Weboberfläche den Workspace öffnen und **zurückgezogene Keys
erneut zurückziehen** (siehe oben). Läuft alles, kann `<id>.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/<id>.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 <BACKUP_DIR>/<zeitstempel>/. "$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.

224
LASTTEST.md Normal file
View file

@ -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.

View file

@ -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` | Workspaces auflisten, anlegen und wählen (nur Plattform-Admin) |
| `/admin/workspaces/{id}/edit` | Workspace umbenennen oder löschen | | `/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/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/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace | | `/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 | | `/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}/e/{bezeichner}/versions/{n}` | Fassung `n` mit ihrer Nachfolgerin vergleichen |
| `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph | | `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph |
| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) | | `/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}/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}/audit` | Protokoll des Projekts |
| `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) | | `/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` | | Signierte Sitzungs-Cookies (HMAC-SHA256) | `internal/auth/session.go` |
| API-Keys nur als SHA-256-Prüfsumme gespeichert | `internal/auth/keys.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` | | 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` | | CSRF-Token in allen Formularen | `internal/auth/csrf.go` |
| `SameSite=Lax`, `HttpOnly`, `Secure` | `internal/auth/cookies.go` | | `SameSite=Lax`, `HttpOnly`, `Secure` | `internal/auth/cookies.go` |
| Rate-Limiting je Adresse, nur auf Fehlversuche | `internal/auth/ratelimit.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 - **Keine Benutzerkonten.** Zugänge sind Keys, keine Personen. Ein Key je Person
ist der Ersatz — geplant ist später Keycloak. ist der Ersatz — geplant ist später Keycloak.
- **Projekte pflegt nur der Plattform-Admin.** Ein Team kann seinen eigenen - **Projekte pflegen Plattform-Admin und Workspace-Admins.** Ein Team mit
Projektnamen nicht selbst korrigieren. Solange es wenige Projekte gibt, ist einem Projekt-Key kann seinen eigenen Projektnamen nicht selbst korrigieren,
eine Berechtigungsstufe weniger den Umweg wert. 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. - **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. - **Ein Plattform-Admin über allem.** Workspace-Admins sind auf ihren
Wer den `ADMIN_TOKEN` hat, kommt an jeden Workspace. 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 - **Das Schreibbudget zählt im Arbeitsspeicher.** Nach einem Neustart steht es
wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche
Zusage ist es nicht. Zusage ist es nicht.
@ -407,6 +412,10 @@ trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jed
## Betrieb ## 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 ### Docker
```bash ```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 Da jede Abteilung ihre eigene Datei hat, lässt sich eine einzelne davon auch
gezielt sichern oder wiederherstellen, ohne die übrigen anzufassen: Server gezielt sichern oder wiederherstellen, ohne die übrigen anzufassen: Server
stoppen, die gewünschte `.db`-Datei aus einem Sicherungsordner zurückkopieren, 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 **Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand
aller Workspaces täglich als Markdown-Dateien dorthin: ein Ordner je Workspace, 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 `./munin -export` endet dann aber mit einem Fehlercode, damit ein Cronjob die
Sicherung nicht für vollständig hält. 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 Der Export ist eine Sicherung, keine zweite Wahrheit — Änderungen an den Dateien
wirken nicht zurück und gehen beim nächsten Lauf verloren. wirken nicht zurück und gehen beim nächsten Lauf verloren.

View file

@ -9,12 +9,14 @@ munin/
│ Mindestlänge), öffnet Kontroll-Datenbank und │ Mindestlänge), öffnet Kontroll-Datenbank und
│ Workspace-Verwaltung (legt beim ersten Start den │ Workspace-Verwaltung (legt beim ersten Start den
│ Workspace "Intern" an), setzt die │ 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 │ Enthält außerdem die Server-Timeouts (gegen
│ Slowloris), die Body-Größenlimits und die │ Slowloris), die Body-Größenlimits und die
│ täglichen Aufgaben (Protokoll aufräumen, │ täglichen Aufgaben (Protokoll aufräumen,
│ Export), die über fuerJedenWorkspace alle │ 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. ├── go.mod / go.sum ← Moduldefinition und Prüfsummen der Abhängigkeiten.
│ │
@ -33,9 +35,14 @@ munin/
│ damit sie den Speicher tatsächlich nutzen: │ damit sie den Speicher tatsächlich nutzen:
│ Projektprompt zum Übernehmen, slug-Regeln, │ Projektprompt zum Übernehmen, slug-Regeln,
│ Muster für einzelne Aufgaben. │ 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, ├── AUSBAUSTUFEN.md ← Was bewusst nicht gebaut wurde: Messwerte,
│ Begründung und die Auslöser, an denen man │ Begründung und die Auslöser, an denen man
│ merkt, dass es Zeit dafür wird. │ merkt, dass es Zeit dafür wird.
├── LASTTEST.md ← Lasttest: Ausführung, Schalter, Ergebnisse mit
│ Datum und ihre Grenzen.
├── REPOMAP.md ← Diese Datei. ├── REPOMAP.md ← Diese Datei.
│ │
├── internal/ ├── internal/
@ -168,7 +175,9 @@ munin/
│ │ │ │ │ │
│ │ ├── session.go ← Signierte Sitzungs-Cookies (HMAC-SHA256). Der │ │ ├── session.go ← Signierte Sitzungs-Cookies (HMAC-SHA256). Der
│ │ │ Cookie trägt nur eine Behauptung ("ich bin Key X"), │ │ │ 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 │ │ ├── csrf.go ← CSRF-Tokens aus Sitzung und Geheimnis, ohne Zustand
│ │ │ auf dem Server. Der Kopfkommentar erklärt den │ │ │ auf dem Server. Der Kopfkommentar erklärt den
@ -214,17 +223,21 @@ munin/
│ │ "?"-Links der Oberfläche darauf zeigen können. │ │ "?"-Links der Oberfläche darauf zeigen können.
│ │ │ │
│ ├── export/ ── Sicherung als Markdown ───────────────────────── │ ├── export/ ── Sicherung als Markdown ─────────────────────────
│ │ └── export.go ← Schreibt jeden Workspace als Ordner │ │ ├── export.go ← Schreibt jeden Workspace als Ordner
│ │ (<workspace-id>/, mit _workspace.md) und darin │ │ │ (<workspace-id>/, mit _workspace.md) und darin
│ │ jedes Projekt als Ordner voller │ │ │ jedes Projekt als Ordner voller
│ │ Markdown-Dateien, mit allen Feldern im Dateikopf. │ │ │ Markdown-Dateien, mit allen Feldern im Dateikopf.
│ │ Kennt selbst keine Datenbank: Der Aufrufer gibt │ │ │ Kennt selbst keine Datenbank: Der Aufrufer gibt
│ │ die Workspace-Datei je Lauf mit. │ │ │ die Workspace-Datei je Lauf mit.
│ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne │ │ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne
│ │ inhaltliche Änderungen fasst keine Datei an und │ │ │ inhaltliche Änderungen fasst keine Datei an und
│ │ erzeugt deshalb auch keinen Diff. Verwaiste │ │ │ erzeugt deshalb auch keinen Diff. Verwaiste
│ │ Dateien gelöschter Einträge werden entfernt, │ │ │ Dateien gelöschter Einträge werden entfernt,
│ │ fremde Dateien im Zielordner bleiben unberührt. │ │ │ 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 ────────────────────────── │ ├── importer/ ── Gegenstück zum Export ──────────────────────────
│ │ ├── archiv.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit │ │ ├── archiv.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit
@ -250,6 +263,11 @@ munin/
│ │ Indizes statt eines gemeinsamen mit Filter: So wird │ │ Indizes statt eines gemeinsamen mit Filter: So wird
│ │ die Abschottung strukturell, und die BM25-Werte │ │ die Abschottung strukturell, und die BM25-Werte
│ │ werden nicht über Projektgrenzen hinweg verzerrt. │ │ 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/ ── Eingebaute Onboarding-Hilfe ───────────────────
│ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel) und │ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel) und
@ -263,7 +281,9 @@ munin/
│ ├── web/ ── HTTP: Browser und API ────────────────────────── │ ├── web/ ── HTTP: Browser und API ──────────────────────────
│ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die │ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die
│ │ │ Ablage im Request-Context. CanAccessProject ist │ │ │ 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?"), │ │ ├── middleware.go ← Sitzungsprüfung (inkl. "gilt der Key noch?"),
│ │ │ CSRF, Projektauflösung aus der URL, Rollenprüfung. │ │ │ CSRF, Projektauflösung aus der URL, Rollenprüfung.
@ -287,9 +307,18 @@ munin/
│ │ │ Dazu Vorschau (preview), Vorschläge für [[…]] │ │ │ Dazu Vorschau (preview), Vorschläge für [[…]]
│ │ │ (suggest), Fassungen und Vergleich, Graph-Daten │ │ │ (suggest), Fassungen und Vergleich, Graph-Daten
│ │ │ und Schlagworte für viele Einträge (bulk-tags). │ │ │ 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), │ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage),
│ │ │ für jede Rolle. Die Admin-Projektliste nutzt │ │ │ für jede Rolle. Die Admin-Projektliste nutzt
│ │ │ dieselben Zahlen (projectListItems). │ │ │ 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), │ │ ├── handlers_import.go ← ZIP-Upload für den Importer (/p/{projekt}/import),
│ │ │ nur mit Rolle admin. Läuft im Server, damit der │ │ │ nur mit Rolle admin. Läuft im Server, damit der
│ │ │ Suchindex im selben Zug nachgezogen wird. │ │ │ Suchindex im selben Zug nachgezogen wird.
@ -307,6 +336,11 @@ munin/
│ │ │ löschen (nur Plattform-Admin). Löscht jemand den │ │ │ löschen (nur Plattform-Admin). Löscht jemand den
│ │ │ Workspace der eigenen Sitzung, wird die Auswahl │ │ │ Workspace der eigenen Sitzung, wird die Auswahl
│ │ │ zurückgesetzt, statt ihn auszusperren. │ │ │ 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 │ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht, Export. Die
│ │ Bearbeitungsseite je Projekt (Name, │ │ Bearbeitungsseite je Projekt (Name,
│ │ Beschreibung, Schreibbudget, Löschen) liegt │ │ Beschreibung, Schreibbudget, Löschen) liegt
@ -346,8 +380,10 @@ munin/
│ ├── autocomplete.js ← Vorschläge nach "[[" im Textfeld, per Tastatur │ ├── autocomplete.js ← Vorschläge nach "[[" im Textfeld, per Tastatur
│ │ und Maus bedienbar. │ │ und Maus bedienbar.
│ ├── graph.js ← Selbst geschriebene Kräftesimulation und │ ├── graph.js ← Selbst geschriebene Kräftesimulation und
│ │ SVG-Darstellung für die Graph-Ansicht. Texte nur │ │ SVG-Darstellung für die Graph-Ansicht. Zoom zieht
│ │ über textContent, nie innerHTML. │ │ 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 │ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne
│ Hintergrund). Die Anmeldeseite wählt je nach │ Hintergrund). Die Anmeldeseite wählt je nach
│ Farbschema die passende. │ 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 in einem Testverzeichnis an, damit auch Migrationen und Fremdschlüssel geprüft
werden. 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 | | Datei | Schwerpunkt |
|---|---| |---|---|
| `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For | | `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/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/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/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/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/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/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/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** | | `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 · ExpectedVersion prüfen → ggf. Konflikt
· Schlagworte und Verweise ersetzen · Schlagworte und Verweise ersetzen
· Herkunft (agent) und Zugangsname setzen · Herkunft (agent) und Zugangsname setzen
→ search/registry.go (Update) Index nachziehen → search/registry.go (Refresh) Index nachziehen
→ store/audit.go (LogAudit) wer, was, wann → store/audit.go (LogAudit) wer, was, wann
→ Antwort: Bezeichner, neue Fassung, angelegt oder aktualisiert → 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/middleware.go (requireWrite) Rollenprüfung
→ web/handlers_entries.go (handleEntrySave) → web/handlers_entries.go (handleEntrySave)
→ store/entries.go (PutEntry) Herkunft: human, Autor: Key-Name → store/entries.go (PutEntry) Herkunft: human, Autor: Key-Name
→ search/registry.go (Update) → search/registry.go (Refresh)
→ store/audit.go (LogAudit) → store/audit.go (LogAudit)
→ Weiterleitung zur Detailansicht → Weiterleitung zur Detailansicht
``` ```

View file

@ -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 ─────────────────────────────────────────────────────────────────── // ── Rollen ───────────────────────────────────────────────────────────────────
func TestRolePermissions(t *testing.T) { func TestRolePermissions(t *testing.T) {
@ -485,3 +510,35 @@ func TestSetTrustedProxiesRejectsGarbage(t *testing.T) {
} }
SetTrustedProxies("") 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")
}
}

View file

@ -11,8 +11,12 @@
// einmalige Anmeldung mit demselben API-Key und trägt danach nur noch eine // einmalige Anmeldung mit demselben API-Key und trägt danach nur noch eine
// signierte Behauptung ("ich bin Key X"), nie den Key selbst. // signierte Behauptung ("ich bin Key X"), nie den Key selbst.
// //
// Dazu kommt der Plattform-Admin (Umgebungsvariable ADMIN_TOKEN), der Projekte // Dazu kommen zwei Verwaltungszugänge, die nicht an ein Projekt gebunden sind
// anlegen und löschen darf — der einzige Zugang, der nicht an ein Projekt gebunden ist. // 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 // # Was hier NICHT liegt
// //

View file

@ -17,6 +17,15 @@ import (
// Secret Scanning und ähnliche) suchen nach genau solchen Präfixen. // Secret Scanning und ähnliche) suchen nach genau solchen Präfixen.
const KeyPrefix = "wk_" 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. // keyBytes ist die Länge des Zufallsanteils eines Keys.
// 32 Byte = 256 Bit — weit jenseits von allem, was sich durchprobieren lässt. // 32 Byte = 256 Bit — weit jenseits von allem, was sich durchprobieren lässt.
const keyBytes = 32 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 // Der Klartext existiert nur genau einmal — direkt nach dem Erzeugen wird er dem
// Nutzer angezeigt und danach nirgends gespeichert (siehe HashKey). // Nutzer angezeigt und danach nirgends gespeichert (siehe HashKey).
func GenerateKey() (string, error) { 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) buf := make([]byte, keyBytes)
if _, err := rand.Read(buf); err != nil { if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("Zufallszahlen konnten nicht erzeugt werden: %w", err) 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. // 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 // Das ist keine Sicherheitsprüfung, sondern spart einen Datenbankzugriff bei
// offensichtlichem Unsinn (leerer Header, abgeschnittener Key, Tippfehler). // offensichtlichem Unsinn (leerer Header, abgeschnittener Key, Tippfehler).
func LooksLikeKey(key string) bool { 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 return false
} }
rest := key[len(KeyPrefix):] rest := key[len(prefix):]
if len(rest) != keyBytes*2 { if len(rest) != keyBytes*2 {
return false return false
} }

View file

@ -22,12 +22,17 @@ const SessionCookieName = "munin_session"
// SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt. // SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt.
const SessionTTL = 30 * 24 * time.Hour 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" const SubjectPlatformAdmin = "platform"
// SubjectKeyPrefix steht vor der Key-ID im Session-Subject einer normalen Anmeldung. // SubjectKeyPrefix steht vor der Key-ID im Session-Subject einer normalen Anmeldung.
const SubjectKeyPrefix = "key:" const SubjectKeyPrefix = "key:"
// SubjectWorkspaceAdminPrefix steht vor "<workspace-id>:<zugangs-id>" im
// Session-Subject eines Workspace-Admins.
const SubjectWorkspaceAdminPrefix = "wsadmin:"
// Signer signiert und prüft alles, was der Server einem Browser mitgibt: // Signer signiert und prüft alles, was der Server einem Browser mitgibt:
// Session-Cookies und CSRF-Tokens. // 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. // SignSession erstellt den Cookie-Wert für ein Subject.
// //
// subject ist entweder SubjectPlatformAdmin oder "key:<api-key-id>". // subject stammt aus PlatformSubject oder beginnt mit SubjectKeyPrefix bzw.
// SubjectWorkspaceAdminPrefix.
// //
// Aufbau: base64("<subject>|<ablauf-unix>") + "." + HMAC // Aufbau: base64("<subject>|<ablauf-unix>") + "." + HMAC
// //
@ -102,6 +108,53 @@ func (s *Signer) VerifySession(cookieValue string) (subject string, ok bool) {
return subject, true 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/<fingerabdruck>" noch kein Workspace gewählt
// "platform/<fingerabdruck>:<workspace-id>" 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. // KeyIDFromSubject holt die API-Key-ID aus einem Session-Subject.
// Beim Plattform-Admin gibt es keine — dann ist ok false. // Beim Plattform-Admin gibt es keine — dann ist ok false.
func KeyIDFromSubject(subject string) (keyID string, ok bool) { func KeyIDFromSubject(subject string) (keyID string, ok bool) {

View file

@ -86,19 +86,16 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st
var stats Stats var stats Stats
// Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim // Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim
// Projekt-Bezeichner direkt darunter. IDs aus store.NewID bestehen nur aus // Projekt-Bezeichner (siehe projektDateien). IDs aus store.NewID bestehen
// Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese Prüfung. // nur aus Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese
// Prüfung.
if !store.ValidSlug(project.WorkspaceID) { if !store.ValidSlug(project.WorkspaceID) {
return stats, fmt.Errorf("ungültige Workspace-ID %q", project.WorkspaceID) return stats, fmt.Errorf("ungültige Workspace-ID %q", project.WorkspaceID)
} }
// Der Projekt-Bezeichner ist beim Anlegen geprüft worden (siehe dateien, err := projektDateien(ctx, s, project)
// store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier if err != nil {
// noch einmal, weil ein Wert aus der Datenbank grundsätzlich nichts return stats, err
// 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)
} }
ziel := filepath.Join(e.root, project.WorkspaceID, project.Slug) 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) 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. // Namen merken, um danach zu wissen, was übrig bleibt.
erwartet := make(map[string]bool, len(eintraege)+1) erwartet := make(map[string]bool, len(dateien))
erwartet[indexDateiname] = true for _, d := range dateien {
// Im Ordner des Projekts zählt nur der Dateiname, ohne den
for _, eintrag := range eintraege { // Projektordner davor.
if !store.ValidSlug(eintrag.Slug) { name := filepath.Base(d.Pfad)
return stats, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug)
}
name := eintrag.Slug + ".md"
erwartet[name] = true 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 return stats, err
} }
stats.Written++ 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) entfernt, err := raeumeAuf(ziel, erwartet)
if err != nil { if err != nil {
return stats, err return stats, err
@ -181,6 +163,50 @@ func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store
return gesamt, nil 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
// "<projekt-slug>/".
//
// 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. // indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird.
const indexDateiname = "_index.md" const indexDateiname = "_index.md"

101
internal/export/zip.go Normal file
View file

@ -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:
//
// <projekt>/
// _index.md
// <eintrag>.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
// <projekt-a>/…
// <projekt-b>/…
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()
}

View file

@ -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 | | `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 | | `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 Darüber stehen zwei Verwaltungszugänge, die nicht an ein Projekt gebunden
Admin-Token an, legt Workspaces und Projekte an und kommt an jedes Projekt. 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.

View file

@ -12,6 +12,10 @@
weitere Keys für ihr Team und ihre Agenten an. Der Verweis auf diese Hilfe weitere Keys für ihr Team und ihre Agenten an. Der Verweis auf diese Hilfe
(`{{SERVER}}/hilfe`) reicht als Einstieg. (`{{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 ## Workspaces
| Aktion | Wo | | Aktion | Wo |
@ -27,6 +31,32 @@ auf diesen Workspace.
Einträgen und Keys. Zur Bestätigung muss der Name eingetippt werden. Rückgängig Einträgen und Keys. Zur Bestätigung muss der Name eingetippt werden. Rückgängig
machen lässt sich das nur aus einer Sicherung. 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 ## Projekte
Unter *Projekte → Verwalten…* stehen alle Projekte des Workspace mit 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. Name, Beschreibung und die **eigene Obergrenze fürs Schreibbudget** ändern.
Dort liegt auch das Löschen. Dort liegt auch das Löschen.
Das Schreibbudget kann nur der Plattform-Admin ändern. Sonst könnte ein Team Das Schreibbudget kann nur der Plattform-Admin ändern, auch ein
die Grenze selbst aufheben, die gerade vor durchdrehenden Agenten schützen soll. Workspace-Admin nicht. Sonst könnte ein Team die Grenze selbst aufheben, die
gerade vor durchdrehenden Agenten schützen soll.
## Protokoll über alle Projekte ## 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 exportiert der Knopf **Jetzt exportieren** sofort den gerade gewählten
Workspace, zum Beispiel vor einer größeren Änderung. 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 Zurück kommt ein Export über den [Import](/hilfe/weboberflaeche#import) im
jeweiligen Projekt: den Projektordner aus dem Export als ZIP-Archiv packen und jeweiligen Projekt: das ZIP eines Projekts direkt hochladen, oder den
hochladen. So lässt sich auch ein einzelnes Projekt wiederherstellen, ohne 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. die ganze Workspace-Datei zurückzuspielen.

View file

@ -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: Schlagworte und Inhalt. Titel zählen am stärksten. Umlaute sind egal:
„Uberblick“ findet auch „Überblick“. Ein Wortanfang reicht, „deploy“ findet „Uberblick“ findet auch „Überblick“. Ein Wortanfang reicht, „deploy“ findet
auch „Deployment“. auch „Deployment“.
- **Nach Schlagwort filtern:** In der Seitenleiste stehen alle Schlagworte des - **Nach Schlagwort filtern:** In der Seitenleiste stehen die zehn häufigsten
Projekts, die häufigsten zuerst. Ein Klick filtert die Liste. 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 - **Verweisen folgen:** Unter jedem Eintrag steht, welche anderen Einträge auf
ihn verweisen. ihn verweisen.
- **Graph:** Unter *Graph* in der Seitenleiste zeigt Munin, wie die Einträge - **Graph:** Unter *Graph* in der Seitenleiste zeigt Munin, wie die Einträge
über Verweise zusammenhängen. Gestrichelte Knoten sind Verweisziele, zu denen über Verweise zusammenhängen. Gestrichelte Knoten sind Verweisziele, zu denen
es noch keinen Eintrag gibt. Mausrad zoomt, Ziehen verschiebt, ein Klick 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. stärksten verknüpften Einträge.
## Schreiben ## 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 lässt sich ein ZIP-Archiv mit Markdown-Dateien hochladen. Jede Datei wird ein
Eintrag. Das passt für drei Fälle: Eintrag. Das passt für drei Fälle:
- **Wiederherstellen** aus dem Markdown-Export: den Ordner eines Projekts - **Wiederherstellen** aus dem Markdown-Export: das ZIP aus *Als ZIP herunterladen*
packen und hochladen. Schlagworte, Autor, Herkunft, Prüfvermerk und direkt hochladen, oder den Ordner eines Projekts aus dem Exportverzeichnis
des Servers packen und hochladen. Schlagworte, Autor, Herkunft, Prüfvermerk und
Änderungsdatum bleiben erhalten. Änderungsdatum bleiben erhalten.
- **Umzug** eines Projekts auf einen anderen Server oder in einen anderen - **Umzug** eines Projekts auf einen anderen Server oder in einen anderen
Workspace, auf demselben Weg. 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 Nicht übernommen wird der Verlauf, denn der Export enthält nur den aktuellen
Stand. Ein importierter Eintrag beginnt deshalb wieder bei Fassung 1. 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 ## Protokoll
Unter *Protokoll* in der Seitenleiste steht, wer im Projekt wann etwas Unter *Protokoll* in der Seitenleiste steht, wer im Projekt wann etwas

View file

@ -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 ein offener Zugang. Wenn niemand mehr weiß, wofür er da ist, am besten
zurückziehen. 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? ## Welche Rolle für wen?
| Wer | Empfohlene Rolle | | Wer | Empfohlene Rolle |

View file

@ -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) { func TestConflictModes(t *testing.T) {
ctx := context.Background() ctx := context.Background()
s, p := testStore(t, "test") s, p := testStore(t, "test")

View file

@ -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)
}

View file

@ -427,7 +427,7 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
return nil, rememberOutput{}, err return nil, rememberOutput{}, err
} }
ws.Search.Update(project.ID, geschrieben) ws.Search.Refresh(ctx, project.ID, geschrieben.Slug)
sess, _ := web.SessionFromContext(ctx) sess, _ := web.SessionFromContext(ctx)
ws.Store.LogAudit(ctx, store.AuditEvent{ ws.Store.LogAudit(ctx, store.AuditEvent{
@ -505,7 +505,7 @@ func (h *Handler) addForget(server *mcp.Server, project store.Project) {
return nil, forgetOutput{}, err return nil, forgetOutput{}, err
} }
ws.Search.Delete(project.ID, in.Slug) ws.Search.Refresh(ctx, project.ID, in.Slug)
sess, _ := web.SessionFromContext(ctx) sess, _ := web.SessionFromContext(ctx)
ws.Store.LogAudit(ctx, store.AuditEvent{ ws.Store.LogAudit(ctx, store.AuditEvent{

View file

@ -2,7 +2,9 @@ package search
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log"
"sync" "sync"
"munin/internal/store" "munin/internal/store"
@ -23,8 +25,32 @@ import (
type Registry struct { type Registry struct {
store *store.Store 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 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 // NewRegistry erstellt eine leere Registry. Die Indizes entstehen beim ersten
@ -32,30 +58,60 @@ type Registry struct {
func NewRegistry(s *store.Store) *Registry { func NewRegistry(s *store.Store) *Registry {
return &Registry{ return &Registry{
store: s, 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. // 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 // Der verzögerte Aufbau hat einen praktischen Grund: Beim Serverstart alle
// Projekte einzulesen würde bei vielen Projekten den Start ausbremsen, obwohl // Projekte einzulesen würde bei vielen Projekten den Start ausbremsen, obwohl
// die meisten davon in dieser Laufzeit vielleicht nie angefragt werden. So zahlt // die meisten davon in dieser Laufzeit vielleicht nie angefragt werden. So zahlt
// nur der erste Zugriff je Projekt. // 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) { func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) {
r.mu.Lock() f := r.fachFuer(projectID)
defer r.mu.Unlock()
if idx, ok := r.indexes[projectID]; ok { f.mu.Lock()
return idx, nil defer f.mu.Unlock()
if f.idx != nil {
return f.idx, nil
} }
idx := NewIndex() idx := NewIndex()
if err := r.fill(ctx, idx, projectID); err != nil { if err := r.fill(ctx, idx, projectID); err != nil {
// Fach bleibt leer, der nächste Zugriff versucht es erneut.
return nil, err return nil, err
} }
r.indexes[projectID] = idx f.idx = idx
return idx, nil 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) return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err)
} }
if r.nachDemLesen != nil {
r.nachDemLesen(projectID)
}
for _, e := range eintraege { for _, e := range eintraege {
idx.Index(e.Slug, e.Title, e.Content, e.Tags) idx.Index(e.Slug, e.Title, e.Content, e.Tags)
} }
return nil 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 // Existiert für das Projekt noch kein Index, passiert nichts — er wird beim
// ersten Zugriff ohnehin vollständig aufgebaut. // ersten Zugriff ohnehin vollständig aufgebaut.
func (r *Registry) Update(projectID string, e store.Entry) { //
r.mu.Lock() // # Warum aus der Datenbank lesen statt den geschriebenen Stand übernehmen?
defer r.mu.Unlock() //
// Speichern und Index aktualisieren sind zwei getrennte Schritte. Schreiben
if idx, ok := r.indexes[projectID]; ok { // zwei Agenten gleichzeitig denselben Eintrag, können sie sich dazwischen
idx.Index(e.Slug, e.Title, e.Content, e.Tags) // ü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. // Läuft gerade der Aufbau dieses Projekts, wartet Refresh hier, bis er
func (r *Registry) Delete(projectID, slug string) { // fertig ist. Das ist wichtig: Der Aufbau hat die Einträge womöglich vor
r.mu.Lock() // dieser Änderung gelesen. Erst danach nachzulesen holt sie sicher nach.
defer r.mu.Unlock() f.mu.Lock()
defer f.mu.Unlock()
if idx, ok := r.indexes[projectID]; ok { if f.idx == nil {
idx.Remove(slug) 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) { func (r *Registry) Forget(projectID string) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
delete(r.indexes, projectID) delete(r.faecher, projectID)
} }

View file

@ -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")
}
}

View file

@ -26,6 +26,12 @@ const (
// einmaliger Import die Nutzungsübersicht (4.6) nicht verzerrt. // einmaliger Import die Nutzungsübersicht (4.6) nicht verzerrt.
ActionEntryImport = "entry_import" 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" ActionLoginSuccess = "login_success"
ActionAuthFailure = "auth_failure" ActionAuthFailure = "auth_failure"
ActionProjectCreated = "project_created" ActionProjectCreated = "project_created"
@ -33,6 +39,11 @@ const (
ActionProjectUpdated = "project_updated" ActionProjectUpdated = "project_updated"
ActionKeyCreated = "key_created" ActionKeyCreated = "key_created"
ActionKeyRevoked = "key_revoked" 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 // Feste Werte für das Feld Detail bei ActionEntryWrite. Feste Wörter statt

View file

@ -98,3 +98,75 @@ func TestListWorkspaces(t *testing.T) {
t.Errorf("unbekannte ID: err = %v, erwartet ErrNotFound", err) 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)
}
}

View file

@ -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);

View file

@ -83,12 +83,19 @@ const UnusedAfter = 90 * 24 * time.Hour
// jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen // jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen
// Zeitpunkt vorgeben können. // Zeitpunkt vorgeben können.
func (k APIKey) Unused(jetzt time.Time) bool { 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 return false
} }
zuletzt := k.LastUsedAt zuletzt := zuletztBenutzt
if zuletzt.IsZero() { if zuletzt.IsZero() {
zuletzt = k.CreatedAt zuletzt = angelegt
} }
return jetzt.Sub(zuletzt) > UnusedAfter return jetzt.Sub(zuletzt) > UnusedAfter
} }

View file

@ -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) { func TestCreateAPIKeyRequiresName(t *testing.T) {
s := testStore(t) s := testStore(t)
p := testProject(t, s) p := testProject(t, s)

View file

@ -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
}

View file

@ -13,20 +13,34 @@ import (
// Session beschreibt, wer gerade zugreift. // 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 // - Plattform-Admin (ADMIN_TOKEN): darf Projekte anlegen und löschen und auf
// jedes Projekt eines gewählten Workspace zugreifen. Key und Project sind // jedes Projekt eines gewählten Workspace zugreifen. Key und Project sind
// leer. WorkspaceID ist leer, solange noch kein Workspace gewählt wurde — // leer. WorkspaceID ist leer, solange noch kein Workspace gewählt wurde —
// eine solche Sitzung darf ausschließlich die Workspace-Auswahl aufrufen // eine solche Sitzung darf ausschließlich die Workspace-Auswahl aufrufen
// (siehe requireWorkspace in middleware.go). // (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-Zugang: an genau einen API-Key gebunden, der zu genau einem
// Projekt in genau einem Workspace gehört. Was er darf, sagt Role. // 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 { type Session struct {
Subject string // für die CSRF-Token-Berechnung Subject string // für die CSRF-Token-Berechnung
IsPlatformAdmin bool IsPlatformAdmin bool
IsWorkspaceAdmin bool
WorkspaceID string WorkspaceID string
WorkspaceName string // nur beim Plattform-Admin gefüllt, für den Umschalter in der Kopfleiste WorkspaceName string // bei Plattform- und Workspace-Admin gefüllt, für die Kopfleiste
WorkspaceAdmin store.WorkspaceAdmin
Key store.APIKey Key store.APIKey
Project store.Project Project store.Project
Role auth.Role Role auth.Role
@ -36,21 +50,64 @@ type Session struct {
// //
// Hier steckt die Mandantentrennung der Weboberfläche: Ein Projekt-Key kommt // Hier steckt die Mandantentrennung der Weboberfläche: Ein Projekt-Key kommt
// ausschließlich an sein eigenes Projekt, ganz gleich welche URL aufgerufen wird. // 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 { func (s Session) CanAccessProject(projectID string) bool {
if s.IsPlatformAdmin { if s.IsPlatformAdmin || s.IsWorkspaceAdmin {
return true return true
} }
return s.Key.ProjectID == projectID return s.Key.ProjectID == projectID
} }
// CanWrite sagt, ob Einträge angelegt oder geändert werden dürfen. // 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 { func (s Session) CanWrite() bool {
return s.IsPlatformAdmin || s.Role.CanWrite() return s.IsPlatformAdmin || s.Role.CanWrite()
} }
// CanManageKeys sagt, ob die Keys eines Projekts verwaltet werden dürfen. // CanManageKeys sagt, ob die Keys eines Projekts verwaltet werden dürfen.
func (s Session) CanManageKeys() bool { 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. // ActorName ist der Name, unter dem diese Sitzung im Audit-Log auftaucht.
@ -58,6 +115,9 @@ func (s Session) ActorName() string {
if s.IsPlatformAdmin { if s.IsPlatformAdmin {
return "platform-admin" return "platform-admin"
} }
if s.IsWorkspaceAdmin {
return "workspace-admin:" + s.WorkspaceAdmin.Name
}
return s.Key.Name return s.Key.Name
} }

View file

@ -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
}

View file

@ -44,7 +44,8 @@ type projectListItem struct {
EigenesLimit int 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) { func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
ws, sess := s.workspaceAndSession(r) 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{ 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), 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. // handleProjectDelete löscht ein Projekt samt Einträgen und Keys.
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
ws, _ := s.workspaceAndSession(r) ws, sess := s.workspaceAndSession(r)
id := r.FormValue("id") id := r.FormValue("id")
projekt, err := ws.Store.ProjectByID(r.Context(), 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.Search.Forget(id)
ws.Store.LogAudit(r.Context(), store.AuditEvent{ 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), Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: projekt.Slug, Detail: projekt.Slug,
}) })
@ -214,8 +215,12 @@ func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) {
} }
// handleProjectEditSave speichert Name, Beschreibung und Obergrenze. // 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) { 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) projekt, ok := s.projectFromSlug(w, r)
if !ok { if !ok {
@ -230,13 +235,17 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
return return
} }
limit := projekt.WriteLimitHourly
if sess.CanEditWriteBudget() {
roh := strings.TrimSpace(r.FormValue("write_limit_hourly")) roh := strings.TrimSpace(r.FormValue("write_limit_hourly"))
limit, err := strconv.Atoi(roh) var err error
limit, err = strconv.Atoi(roh)
if err != nil || limit < 0 { if err != nil || limit < 0 {
s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "",
"Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).") "Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).")
return return
} }
}
aenderungen := projektAenderungen(projekt, name, beschreibung, limit) aenderungen := projektAenderungen(projekt, name, beschreibung, limit)
if len(aenderungen) == 0 { if len(aenderungen) == 0 {
@ -258,7 +267,7 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
// interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine // interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine
// Betriebsentscheidung. // Betriebsentscheidung.
ws.Store.LogAudit(r.Context(), store.AuditEvent{ 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), Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: strings.Join(aenderungen, "; "), 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. // projectFromSlug löst den Bezeichner aus der URL zu einem Projekt auf.
// //
// Eigene Funktion statt der Middleware requireProject: Die liegt unter // Eigene Funktion statt der Middleware requireProject: Die liegt unter
// /p/{project}/ und prüft den Zugriff eines Projekt-Zugangs. Hier ist ohnehin // /p/{project}/ und prüft den Zugriff eines Projekt-Zugangs. Hier sind ohnehin
// schon nur der Plattform-Admin unterwegs (siehe Routes), und der Bezeichner // nur Plattform- und Workspace-Admin unterwegs (siehe Routes), und der
// steht unter einem anderen Platzhalter. // 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) { func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) {
ws, sess := s.workspaceAndSession(r) 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) 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) { func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) {
s.renderAudit(w, r, store.AuditFilter{}, true, "Protokoll") s.renderAudit(w, r, store.AuditFilter{}, true, "Protokoll")
} }

View file

@ -15,6 +15,10 @@ import (
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
sess, _ := SessionFrom(r) sess, _ := SessionFrom(r)
if sess.IsWorkspaceAdmin {
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
return
}
if sess.IsPlatformAdmin { if sess.IsPlatformAdmin {
if sess.WorkspaceID == "" { if sess.WorkspaceID == "" {
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther) 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. // resolveLoginKey prüft einen Key beim Anmelden und gibt das Session-Subject zurück.
// //
// Der Plattform-Admin bekommt hier noch keinen Workspace zugewiesen — das // 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 // Workspace gewählt und das Subject um dessen ID ergänzt (siehe
// handleWorkspaceSelect). // handleWorkspaceSelect).
func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) { 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. // verglichen wird und die Antwortzeit nichts verrät.
if auth.HashKey(key) == s.adminTokenHash { if auth.HashKey(key) == s.adminTokenHash {
log.Printf("Anmeldung Plattform-Admin von %s", auth.ClientIP(r)) 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) { 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 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. // handleLogout löscht den Session-Cookie.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{ cookie := &http.Cookie{
@ -127,6 +160,12 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther) 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. // setSessionCookie signiert das Subject und setzt es als Cookie.
func (s *Server) setSessionCookie(w http.ResponseWriter, r *http.Request, subject string) { func (s *Server) setSessionCookie(w http.ResponseWriter, r *http.Request, subject string) {
cookie := &http.Cookie{ cookie := &http.Cookie{

View file

@ -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
}

View file

@ -353,7 +353,7 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
return return
} }
ws.Search.Update(project.ID, geschrieben) ws.Search.Refresh(r.Context(), project.ID, geschrieben.Slug)
ws.Store.LogAudit(r.Context(), store.AuditEvent{ ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb, Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb,
@ -398,7 +398,7 @@ func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
ws.Search.Delete(project.ID, slug) ws.Search.Refresh(r.Context(), project.ID, slug)
ws.Store.LogAudit(r.Context(), store.AuditEvent{ ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb, 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 { for _, e := range geaendert {
ws.Search.Update(project.ID, e) ws.Search.Refresh(r.Context(), project.ID, e.Slug)
} }
if len(geaendert) > 0 { if len(geaendert) > 0 {
@ -610,7 +610,7 @@ func (s *Server) handleEntryRestore(w http.ResponseWriter, r *http.Request) {
return return
} }
ws.Search.Update(project.ID, geschrieben) ws.Search.Refresh(r.Context(), project.ID, geschrieben.Slug)
ws.Store.LogAudit(r.Context(), store.AuditEvent{ ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryWrite, EntrySlug: e.Slug, Source: store.SourceWeb, Action: store.ActionEntryWrite, EntrySlug: e.Slug, Source: store.SourceWeb,

View file

@ -18,6 +18,10 @@ package web
// Nur mit der Rolle admin, wie die Zugänge. Ein Import schreibt womöglich // 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, // tausende Einträge auf einmal, und wer ihn im Modus „Überschreiben“ startet,
// ändert Einträge, die andere geschrieben haben. // ä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 ( import (
"errors" "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 // Deshalb Index und Protokoll in jedem Fall nachziehen, erst danach den
// Fehler melden. // Fehler melden.
for _, e := range bericht.Geschrieben { 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 { if !daten.Vorschau && len(bericht.Geschrieben) > 0 {
// Ein Protokolleintrag für den ganzen Import statt einer je Datei. // Ein Protokolleintrag für den ganzen Import statt einer je Datei.

View file

@ -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 // eine eigene Protokollzeile — so lässt sich im Protokoll nach einem
// einzelnen Bezeichner filtern und sein ganzer Weg nachvollziehen. // einzelnen Bezeichner filtern und sein ganzer Weg nachvollziehen.
for _, e := range zurueck { 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{ ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryRestore, EntrySlug: e.Slug, Source: store.SourceWeb, Action: store.ActionEntryRestore, EntrySlug: e.Slug, Source: store.SourceWeb,

View file

@ -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))
}

View file

@ -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,
})
}

View file

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
"munin/internal/store" "munin/internal/store"
) )
@ -24,6 +25,11 @@ type workspaceItem struct {
Name string Name string
CreatedAt string CreatedAt string
Projects int 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. // handleWorkspaceList zeigt alle Workspaces zur Auswahl.
@ -45,6 +51,23 @@ func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, sta
return 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 // Neueste zuerst — genau umgekehrt zu ListWorkspaces, das für
// EnsureDefaultWorkspace die älteste zuerst braucht. // EnsureDefaultWorkspace die älteste zuerst braucht.
items := make([]workspaceItem, len(workspaces)) items := make([]workspaceItem, len(workspaces))
@ -52,6 +75,8 @@ func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, sta
items[len(workspaces)-1-i] = workspaceItem{ items[len(workspaces)-1-i] = workspaceItem{
ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02"), 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) sess, _ := SessionFrom(r)
log.Printf("Workspace %q (%s) angelegt von %s", ws.Name, ws.ID, sess.ActorName()) 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) 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 { type workspaceEditData struct {
Workspace workspaceItem Workspace workspaceItem
Projects int 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. // 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 { if !ok {
return return
} }
s.renderWorkspaceEdit(w, r, http.StatusOK, ws, "", "") s.renderWorkspaceEdit(w, r, http.StatusOK, ws, workspaceEditData{}, "", "")
} }
// handleWorkspaceEditSave benennt einen Workspace um. // 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")) name := strings.TrimSpace(r.FormValue("name"))
if 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 return
} }
@ -142,7 +181,7 @@ func (s *Server) handleWorkspaceDelete(w http.ResponseWriter, r *http.Request) {
} }
if r.FormValue("confirm") != ws.Name { 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.") "Zum Löschen muss der Workspace-Name \""+ws.Name+"\" zur Bestätigung eingegeben werden.")
return 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()) log.Printf("Workspace %q (%s) gelöscht von %s", ws.Name, ws.ID, sess.ActorName())
if sess.WorkspaceID == ws.ID { 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) 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) 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, 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{ s.render(w, r, "workspace-edit.html", status, pageData{
Title: ws.Name, Title: ws.Name,
Bereich: "workspaces", Bereich: "workspaces",
Flash: flash, Flash: flash,
Error: fehler, 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 return
} }
s.setSessionCookie(w, r, "platform:"+id) s.setSessionCookie(w, r, s.plattformSubjekt(id))
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
} }

View file

@ -1,6 +1,7 @@
package web package web
import ( import (
"crypto/subtle"
"errors" "errors"
"fmt" "fmt"
"log" "log"
@ -69,18 +70,34 @@ func (s *Server) requireSession(next http.Handler) http.Handler {
// sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung. // 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/<fingerabdruck>" — Plattform-Admin, noch kein Workspace
// - "platform:<workspace-id>" — Plattform-Admin, mit gewähltem Workspace. // gewählt. Der Fingerabdruck muss zum aktuellen ADMIN_TOKEN passen
// (siehe auth.PlatformSubject).
// - "platform/<fingerabdruck>:<workspace-id>" — Plattform-Admin, mit
// gewähltem Workspace.
// - "wsadmin:<workspace-id>:<zugangs-id>" — Workspace-Admin, fest an seinen
// Workspace gebunden (AUSBAUSTUFEN.md 4.8).
// - "key:<workspace-id>:<key-id>" — Projekt-Zugang, immer an genau einen // - "key:<workspace-id>:<key-id>" — Projekt-Zugang, immer an genau einen
// Workspace gebunden (den seines Keys). // Workspace gebunden (den seines Keys).
// //
// Die Workspace-ID steht direkt im (signierten, also fälschungssicheren) // Die Workspace-ID steht direkt im (signierten, also fälschungssicheren)
// Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank // Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank
// der richtigen Workspace-Datei zuordnen. // 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) { func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) {
if subject == auth.SubjectPlatformAdmin { 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 workspaceID == "" {
return Session{ return Session{
Subject: subject, Subject: subject,
IsPlatformAdmin: true, IsPlatformAdmin: true,
@ -88,20 +105,23 @@ func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, b
}, true }, true
} }
if rest, ok := strings.CutPrefix(subject, auth.SubjectPlatformAdmin+":"); ok { ws, err := s.control.WorkspaceByID(r.Context(), workspaceID)
ws, err := s.control.WorkspaceByID(r.Context(), rest)
if err != nil { if err != nil {
return Session{}, false return Session{}, false
} }
return Session{ return Session{
Subject: subject, Subject: subject,
IsPlatformAdmin: true, IsPlatformAdmin: true,
WorkspaceID: rest, WorkspaceID: workspaceID,
WorkspaceName: ws.Name, WorkspaceName: ws.Name,
Role: auth.RoleAdmin, Role: auth.RoleAdmin,
}, true }, true
} }
if rest, ok := strings.CutPrefix(subject, auth.SubjectWorkspaceAdminPrefix); ok {
return s.workspaceAdminSession(r, subject, rest)
}
rest, ok := auth.KeyIDFromSubject(subject) rest, ok := auth.KeyIDFromSubject(subject)
if !ok { if !ok {
return Session{}, false return Session{}, false
@ -135,6 +155,45 @@ func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, b
}, true }, true
} }
// workspaceAdminSession baut die Sitzung eines Workspace-Admins aus dem Rest
// seines Subjects ("<workspace-id>:<zugangs-id>").
//
// 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. // checkCSRF prüft bei schreibenden Requests das CSRF-Token.
// //
// GET, HEAD und OPTIONS gelten als sichere Methoden — sie sollen nichts // 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. // requireWorkspace lässt nur Sitzungen mit gewähltem Workspace durch.
// //
// Betrifft nur den Plattform-Admin: Eine Projekt-Sitzung hat ihren Workspace // Betrifft nur den Plattform-Admin: Projekt- und Workspace-Admin-Sitzungen
// immer schon über ihren Key (siehe sessionFromSubject). Der Plattform-Admin // haben ihren Workspace immer schon im Subject (siehe sessionFromSubject). Der
// Plattform-Admin
// dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er // dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er
// verwalten will — dafür ist /admin/workspaces da. // verwalten will — dafür ist /admin/workspaces da.
func (s *Server) requireWorkspace(next http.Handler) http.Handler { 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. // requireKeyManagement lässt nur Sitzungen durch, die Keys verwalten dürfen.
func (s *Server) requireKeyManagement(next http.Handler) http.Handler { func (s *Server) requireKeyManagement(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 ─────────────────────────────────────────────── // ── Allgemeine Schutzschichten ───────────────────────────────────────────────
// SecurityHeaders setzt bei jeder Antwort die Schutz-Header des Browsers. // SecurityHeaders setzt bei jeder Antwort die Schutz-Header des Browsers.

View file

@ -39,6 +39,11 @@ type Server struct {
// nicht der Klartext — dann liegt das Geheimnis nicht dauerhaft im Speicher // 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. // herum und der Vergleich läuft über dieselbe Funktion wie bei den API-Keys.
adminTokenHash string 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. // Config sammelt die Angaben zum Bauen eines Servers.
@ -63,6 +68,8 @@ func NewServer(cfg Config) (*Server, error) {
return nil, err return nil, err
} }
adminTokenHash := auth.HashKey(cfg.AdminToken)
return &Server{ return &Server{
workspaces: cfg.Workspaces, workspaces: cfg.Workspaces,
control: cfg.Control, control: cfg.Control,
@ -72,7 +79,9 @@ func NewServer(cfg Config) (*Server, error) {
templates: tmpl, templates: tmpl,
exporter: cfg.Exporter, exporter: cfg.Exporter,
writeBudget: cfg.WriteBudget, writeBudget: cfg.WriteBudget,
adminTokenHash: auth.HashKey(cfg.AdminToken), adminTokenHash: adminTokenHash,
adminFingerprint: cfg.Signer.Fingerprint(adminTokenHash),
}, nil }, 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. // /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("GET /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm)))
mux.Handle("POST /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave))) 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 // Plattform-Admin einen Workspace gewählt hat, bevor er dessen Projekte
// verwaltet — siehe requireWorkspace in middleware.go. // 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 { imWorkspace := func(h http.HandlerFunc) http.Handler {
return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h))) return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h)))
} }
mux.Handle("GET /admin/projects", imWorkspace(s.handleProjectList)) projektVerwaltung := func(h http.HandlerFunc) http.Handler {
mux.Handle("POST /admin/projects/create", imWorkspace(s.handleProjectCreate)) return s.requireSession(s.requireWorkspace(s.requireProjectManagement(h)))
mux.Handle("POST /admin/projects/delete", imWorkspace(s.handleProjectDelete)) }
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 // Die Bearbeitung liegt unter {slug}/edit statt direkt unter {slug}: Sonst
// wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen // wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen
// Projekt-Bezeichner. // Projekt-Bezeichner.
mux.Handle("GET /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditForm)) mux.Handle("GET /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditForm))
mux.Handle("POST /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditSave)) mux.Handle("POST /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditSave))
mux.Handle("GET /admin/audit", imWorkspace(s.handleGlobalAudit)) mux.Handle("GET /admin/audit", projektVerwaltung(s.handleGlobalAudit))
mux.Handle("POST /admin/export", imWorkspace(s.handleExport)) 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. // Projektseiten.
mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList)) 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("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", imProjekt(s.handleEntryGraph))
mux.Handle("GET /p/{project}/graph/data", imProjekt(s.handleEntryGraphData)) 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("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}/save", mitSchreibrecht(s.handleEntrySave))
mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete)) 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)) mux.Handle("GET /p/{project}/usage", imProjekt(s.handleProjectUsage))
// Key-Verwaltung. // 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))))) 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. // Import: Rolle admin mit Schreibrecht, siehe Session.CanImport und
// Die größere Body-Grenze für POST setzt LimitBodies (middleware.go). // handlers_import.go. Die größere Body-Grenze für POST setzt LimitBodies
mux.Handle("GET /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImportForm))))) // (middleware.go).
mux.Handle("POST /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImport))))) 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/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))))) 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 ExportEnabled bool
// Bereich markiert den aktiven Punkt in der Seitenleiste: // 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 Bereich string
// Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit // 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. // navData sind die Inhalte der Seitenleiste.
type navData struct { type navData struct {
// Projects ist nur beim Plattform-Admin gefüllt — ein Projekt-Key hat // Projects ist nur bei Plattform- und Workspace-Admin gefüllt — ein
// ohnehin nur Zugriff auf sein eigenes Projekt. // Projekt-Key hat ohnehin nur Zugriff auf sein eigenes Projekt.
Projects []store.Project 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 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. // render führt ein Template aus und schickt das Ergebnis an den Browser.
// //
// Gerendert wird zuerst in einen Puffer im Arbeitsspeicher. Erst wenn das // 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.Project = project
} }
data.ExportEnabled = s.exporter != nil 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 var puffer bytes.Buffer
if err := s.templates.ExecuteTemplate(&puffer, name, data); err != nil { 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 // Fehler brechen die Seite nicht ab: Eine unvollständige Navigation ist
// ärgerlich, aber kein Grund, den eigentlichen Inhalt nicht anzuzeigen. // ä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 var nav navData
// Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl) gibt // 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 return nav
} }
if sess.IsPlatformAdmin { if sess.CanManageProjects() {
projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
if err != nil { if err != nil {
log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err) 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 { if err != nil {
log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err) log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err)
} else { } else {
nav.Tags = tags nav.Tags = kuerzeTags(tags, navTagLimit, aktivesTag)
nav.TagsGesamt = len(tags)
} }
} }
return nav 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. // workspaceAndSession liest Workspace und Sitzung der aktuellen Anfrage.
// //
// Nur für Handler gedacht, die über requireWorkspace oder requireProject // Nur für Handler gedacht, die über requireWorkspace oder requireProject

161
internal/web/tags_test.go Normal file
View file

@ -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(`<span class="nav-text">tag-%02d</span>`, 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, "<h2>T</h2>") {
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)
}
}

View file

@ -124,13 +124,21 @@ func erzeugeKey(t *testing.T, srv *Server, db *store.Store, projectID, name stri
return klartext 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 // waehleWorkspace wählt für eine Plattform-Admin-Sitzung einen Workspace und
// gibt den neuen Session-Cookie zurück — der Schritt, den handleWorkspaceList // gibt den neuen Session-Cookie zurück — der Schritt, den handleWorkspaceList
// in der Oberfläche anbietet (siehe handlers_workspaces.go). // in der Oberfläche anbietet (siehe handlers_workspaces.go).
func waehleWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie { func waehleWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie {
t.Helper() 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}} formular := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(formular.Encode())) r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded") 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) cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)). token := auth.NewSigner([]byte(testSecret)).
CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID) CSRFToken(plattformSubjekt(projekt.WorkspaceID))
formular := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}} formular := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(formular.Encode())) 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) cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)). token := auth.NewSigner([]byte(testSecret)).
CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID) CSRFToken(plattformSubjekt(projekt.WorkspaceID))
formular := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}} formular := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+projekt.WorkspaceID+"/edit", 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) cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)). 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}} formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode())) 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) cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)). 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}} formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode())) 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:<id>") 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 ─────────────────────────────────────────────────────────────────── // ── Rollen ───────────────────────────────────────────────────────────────────
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang keine Einträge ändern kann. // 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) cookie := anmeldenAlsPlattformAdmin(t, h, workspaceID)
formular.Set(auth.CSRFFieldName, 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 := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded") 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) 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}} formular := url.Values{auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/export", strings.NewReader(formular.Encode())) r := httptest.NewRequest(http.MethodPost, "/admin/export", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.Header.Set("Content-Type", "application/x-www-form-urlencoded")

View file

@ -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(`<td class="zahl">\s*2\s*</td>`).MatchString(body) {
t.Error("die Übersicht zeigt nicht 2 aktive Workspace-Admins")
}
if strings.Contains(body, "ungenutzt</a>") {
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")
}
}

425
last_test.go Normal file
View file

@ -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()
}

111
main.go
View file

@ -179,59 +179,11 @@ func main() {
return return
} }
// ── Bestandteile ───────────────────────────────────────────────────────── handler, err := baueHandler(control, workspaces, exporter, cfg)
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")
if err != nil { if err != nil {
log.Fatal(err) 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 ────────────────────────────────────────────── // ── Wiederkehrende Aufgaben ──────────────────────────────────────────────
// //
// Beide laufen bei jedem Durchgang über ALLE Workspaces — auch über die, // Beide laufen bei jedem Durchgang über ALLE Workspaces — auch über die,
@ -291,6 +243,67 @@ func main() {
log.Println("Server gestoppt.") 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. // config sammelt alle Einstellungen aus der Umgebung.
type config struct { type config struct {
// dataDir enthält die Kontroll-Datenbank (control.db) und den Ordner // dataDir enthält die Kontroll-Datenbank (control.db) und den Ordner

View file

@ -34,6 +34,10 @@
# Platz unter DATA_DIR zurückkopieren, Server wieder starten. Für einen # Platz unter DATA_DIR zurückkopieren, Server wieder starten. Für einen
# einzelnen Workspace reicht dessen Datei unter workspaces/ — die # einzelnen Workspace reicht dessen Datei unter workspaces/ — die
# Kontroll-Datenbank bleibt davon unberührt. # 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 set -euo pipefail

View file

@ -9,11 +9,29 @@
// fortlaufend animiert: Bei der serverseitig gedeckelten Knotenzahl (siehe // fortlaufend animiert: Bei der serverseitig gedeckelten Knotenzahl (siehe
// graphNodeLimit in internal/web/handlers_entries.go) ist das in Bruchteilen // graphNodeLimit in internal/web/handlers_entries.go) ist das in Bruchteilen
// einer Sekunde fertig. // 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 () { (function () {
"use strict"; "use strict";
var SVG_NS = "http://www.w3.org/2000/svg"; 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) { document.querySelectorAll("svg[data-graph]").forEach(function (svg) {
ladeUndZeichne(svg); ladeUndZeichne(svg);
}); });
@ -59,8 +77,11 @@
return; 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 breite = svg.clientWidth || 800;
var hoehe = 560; var hoehe = svg.clientHeight || 560;
svg.setAttribute("viewBox", "0 0 " + breite + " " + hoehe); svg.setAttribute("viewBox", "0 0 " + breite + " " + hoehe);
var indexBySlug = {}; var indexBySlug = {};
@ -75,43 +96,70 @@
return p[0] !== undefined && p[1] !== undefined; 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) { var slugs = knoten.map(function (k) {
return k.slug; return k.slug;
}); });
var positionen = simuliere(knoten.length, kantenIdx, breite, hoehe, slugs); var positionen = simuliere(knoten.length, kantenIdx, radien, slugs);
var viewport = svgEl("g", {});
svg.appendChild(viewport);
var kantenGruppe = svgEl("g", { class: "graph-kanten" }); var kantenGruppe = svgEl("g", { class: "graph-kanten" });
var knotenGruppe = svgEl("g", { class: "graph-knoten" }); var knotenGruppe = svgEl("g", { class: "graph-knoten" });
viewport.appendChild(kantenGruppe); svg.appendChild(kantenGruppe);
viewport.appendChild(knotenGruppe); svg.appendChild(knotenGruppe);
kantenIdx.forEach(function (paar) { var kantenEls = kantenIdx.map(function (paar) {
var von = positionen[paar[0]]; var linie = svgEl("line", { class: "graph-kante" });
var nach = positionen[paar[1]]; kantenGruppe.appendChild(linie);
kantenGruppe.appendChild( return linie;
svgEl("line", { x1: von.x, y1: von.y, x2: nach.x, y2: nach.y, class: "graph-kante" }),
);
}); });
knoten.forEach(function (k, i) { var knotenEls = knoten.map(function (k, i) {
var pos = positionen[i];
var radius = 6 + Math.min(14, Math.sqrt(k.degree || 0) * 3);
var gruppe = svgEl("g", { var gruppe = svgEl("g", {
class: "graph-knoten-gruppe" + (k.exists ? "" : " graph-knoten-fehlend"), class: "graph-knoten-gruppe" + (k.exists ? "" : " graph-knoten-fehlend"),
transform: "translate(" + pos.x.toFixed(1) + "," + pos.y.toFixed(1) + ")", "data-index": i,
}); });
// <title> 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"); var titelEl = document.createElementNS(SVG_NS, "title");
titelEl.textContent = k.title; titelEl.textContent = k.title;
gruppe.appendChild(titelEl); 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); text.textContent = kuerze(k.title, 28);
gruppe.appendChild(text); gruppe.appendChild(text);
@ -121,9 +169,243 @@
} }
knotenGruppe.appendChild(gruppe); 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, // fnv1a bildet aus einer Zeichenkette einen 32-Bit-Hash — hier verwendet,
@ -158,10 +440,16 @@
// Klassische Kräftesimulation: Abstoßung zwischen allen Knotenpaaren // Klassische Kräftesimulation: Abstoßung zwischen allen Knotenpaaren
// (Coulomb-artig), Anziehung entlang der Kanten (Federgesetz) und eine // (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 // 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 // einstelligen Millionenbereich an Rechenschritten. Das läuft im Browser
// in Bruchteilen einer Sekunde. // in Bruchteilen einer Sekunde.
// //
@ -170,25 +458,31 @@
// Startpositionen und damit, weil die Kräfte selbst keine Zufallszahlen // Startpositionen und damit, weil die Kräfte selbst keine Zufallszahlen
// mehr ziehen, dasselbe Endlayout. Ändert sich die Knotenmenge, ändert // mehr ziehen, dasselbe Endlayout. Ändert sich die Knotenmenge, ändert
// sich auch der Seed, und das Layout darf sich neu einpendeln. // 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 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 = []; var positionen = [];
for (var i = 0; i < n; i++) { for (var i = 0; i < n; i++) {
positionen.push({ positionen.push({
x: breite / 2 + (zufall() - 0.5) * breite * 0.6, x: (zufall() - 0.5) * spanne,
y: hoehe / 2 + (zufall() - 0.5) * hoehe * 0.6, y: (zufall() - 0.5) * spanne,
vx: 0, vx: 0,
vy: 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 iter = 0; iter < ITERATIONEN; iter++) {
for (var a = 0; a < n; a++) { for (var a = 0; a < n; a++) {
for (var b = a + 1; b < n; b++) { for (var b = a + 1; b < n; b++) {
@ -197,6 +491,10 @@
var distQ = dx * dx + dy * dy || 0.01; var distQ = dx * dx + dy * dy || 0.01;
var dist = Math.sqrt(distQ); var dist = Math.sqrt(distQ);
var kraft = ABSTOSSUNG / 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 fx = (dx / dist) * kraft;
var fy = (dy / dist) * kraft; var fy = (dy / dist) * kraft;
positionen[a].vx += fx; positionen[a].vx += fx;
@ -222,8 +520,8 @@
}); });
for (var k = 0; k < n; k++) { for (var k = 0; k < n; k++) {
positionen[k].vx += (breite / 2 - positionen[k].x) * 0.002; positionen[k].vx -= positionen[k].x * 0.002;
positionen[k].vy += (hoehe / 2 - positionen[k].y) * 0.002; positionen[k].vy -= positionen[k].y * 0.002;
} }
for (var m = 0; m < n; m++) { for (var m = 0; m < n; m++) {
positionen[m].vx *= DAEMPFUNG; positionen[m].vx *= DAEMPFUNG;
@ -236,22 +534,21 @@
return positionen; 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 // Der naheliegende Ansatz — Mausbewegung in Bildschirm-Pixeln direkt als
// Verschiebung im viewBox-Koordinatensystem übernehmen — geht nur auf, // Verschiebung im viewBox-Koordinatensystem übernehmen — geht nur auf,
// wenn die dargestellte Größe der SVG-Fläche exakt ihrer viewBox // wenn die dargestellte Größe der SVG-Fläche exakt ihrer viewBox
// entspricht. Das stimmt zufällig beim ersten Zeichnen (viewBox-Breite // entspricht. Das stimmt zufällig beim ersten Zeichnen (viewBox-Größe
// kommt von svg.clientWidth), aber nicht mehr nach einer // kommt von svg.clientWidth/clientHeight), aber nicht mehr nach einer
// Fenstergröße-Änderung (die SVG-Breite ist "100%" und wächst/schrumpft // Fenstergröße-Änderung (die SVG-Breite ist "100%" und wächst/schrumpft
// mit), und auch sonst nicht zuverlässig (abweichende rem-Basis, // mit), und auch sonst nicht zuverlässig (abweichende rem-Basis,
// Browser-Zoom). zuViewBox() rechnet deshalb bei jeder Bewegung über // Browser-Zoom). zuViewBox() rechnet deshalb bei jeder Bewegung über
// getBoundingClientRect() neu um, statt sich auf diese Übereinstimmung // getBoundingClientRect() neu um, statt sich auf diese Übereinstimmung
// zu verlassen. // zu verlassen.
function aktiviereZoomUndPan(svg, viewport, breite, hoehe, basisUrl) { function aktiviereZoomUndPan(svg, ansicht, grenzen, breite, hoehe, basisUrl, beiAenderung) {
var massstab = 1;
var x = 0;
var y = 0;
var ziehtGerade = false; var ziehtGerade = false;
var startPunkt = { x: 0, y: 0 }; var startPunkt = { x: 0, y: 0 };
var startX = 0; var startX = 0;
@ -262,10 +559,6 @@
// Ziehen statt als Klick. // Ziehen statt als Klick.
var ZIEHSCHWELLE = 6; var ZIEHSCHWELLE = 6;
function anwenden() {
viewport.setAttribute("transform", "translate(" + x + "," + y + ") scale(" + massstab + ")");
}
function zuViewBox(clientX, clientY) { function zuViewBox(clientX, clientY) {
var rect = svg.getBoundingClientRect(); var rect = svg.getBoundingClientRect();
return { return {
@ -282,8 +575,8 @@
bewegungsstrecke = 0; bewegungsstrecke = 0;
gedruecktesElement = event.target.closest(".graph-klickbar"); gedruecktesElement = event.target.closest(".graph-klickbar");
startPunkt = zuViewBox(event.clientX, event.clientY); startPunkt = zuViewBox(event.clientX, event.clientY);
startX = x; startX = ansicht.x;
startY = y; startY = ansicht.y;
svg.classList.add("graph-zieht"); svg.classList.add("graph-zieht");
svg.setPointerCapture(event.pointerId); svg.setPointerCapture(event.pointerId);
}); });
@ -293,11 +586,10 @@
return; return;
} }
var jetzt = zuViewBox(event.clientX, event.clientY); var jetzt = zuViewBox(event.clientX, event.clientY);
// Die Verschiebung (translate) der Gruppe lebt im selben // Die Verschiebung lebt im Bildschirm-Koordinatensystem der
// Koordinatensystem wie die viewBox der äußeren SVG — deshalb // viewBox — deshalb hier ohne weitere Umrechnung durch massstab:
// hier ohne weitere Umrechnung durch massstab: Eine Bewegung um // Eine Bewegung um 10 viewBox-Einheiten verschiebt den Inhalt um
// 10 viewBox-Einheiten verschiebt den Inhalt um genau 10 // genau 10 Einheiten, unabhängig vom aktuellen Zoomstand.
// Einheiten, unabhängig vom aktuellen Zoomstand.
// //
// Der größte je erreichte Abstand zum Startpunkt zählt, nicht // Der größte je erreichte Abstand zum Startpunkt zählt, nicht
// die Summe aller Zwischenschritte — sonst würde allein die // die Summe aller Zwischenschritte — sonst würde allein die
@ -306,9 +598,9 @@
// einen stillstehenden Klick als Ziehen werten. // einen stillstehenden Klick als Ziehen werten.
var abstand = Math.abs(jetzt.x - startPunkt.x) + Math.abs(jetzt.y - startPunkt.y); var abstand = Math.abs(jetzt.x - startPunkt.x) + Math.abs(jetzt.y - startPunkt.y);
bewegungsstrecke = Math.max(bewegungsstrecke, abstand); bewegungsstrecke = Math.max(bewegungsstrecke, abstand);
x = startX + (jetzt.x - startPunkt.x); ansicht.x = startX + (jetzt.x - startPunkt.x);
y = startY + (jetzt.y - startPunkt.y); ansicht.y = startY + (jetzt.y - startPunkt.y);
anwenden(); beiAenderung();
}); });
function beendeZiehen(event) { function beendeZiehen(event) {
@ -344,20 +636,20 @@
"wheel", "wheel",
function (event) { function (event) {
event.preventDefault(); event.preventDefault();
var faktor = event.deltaY < 0 ? 1.1 : 0.9; var faktor = event.deltaY < 0 ? 1.15 : 1 / 1.15;
var neuerMassstab = Math.min(4, Math.max(0.25, massstab * faktor)); var neuerMassstab = Math.min(grenzen.max, Math.max(grenzen.min, ansicht.massstab * faktor));
if (neuerMassstab === massstab) { if (neuerMassstab === ansicht.massstab) {
return; return;
} }
// Zoomt zur Cursor-Position statt zur Bildecke: Der Punkt // Zoomt zur Cursor-Position statt zur Bildecke: Der Punkt
// unter dem Mauszeiger bleibt an derselben Stelle stehen, // unter dem Mauszeiger bleibt an derselben Stelle stehen,
// statt dass der Inhalt beim Zoomen "wegläuft". // statt dass der Inhalt beim Zoomen "wegläuft".
var zeiger = zuViewBox(event.clientX, event.clientY); var zeiger = zuViewBox(event.clientX, event.clientY);
var verhaeltnis = neuerMassstab / massstab; var verhaeltnis = neuerMassstab / ansicht.massstab;
x = zeiger.x - (zeiger.x - x) * verhaeltnis; ansicht.x = zeiger.x - (zeiger.x - ansicht.x) * verhaeltnis;
y = zeiger.y - (zeiger.y - y) * verhaeltnis; ansicht.y = zeiger.y - (zeiger.y - ansicht.y) * verhaeltnis;
massstab = neuerMassstab; ansicht.massstab = neuerMassstab;
anwenden(); beiAenderung();
}, },
{ passive: false }, { passive: false },
); );

View file

@ -59,7 +59,7 @@
/* Breiten. Die Seite nutzt den Platz aus; nur der Fließtext begrenzt sich /* Breiten. Die Seite nutzt den Platz aus; nur der Fließtext begrenzt sich
* selbst (siehe Regel zwei oben). */ * selbst (siehe Regel zwei oben). */
--breite-seite: 92rem; /* Seitenleiste plus Inhalt */ --breite-seite: 120rem; /* Seitenleiste plus Inhalt */
--breite-leiste: 15rem; /* Seitenleiste */ --breite-leiste: 15rem; /* Seitenleiste */
--breite-lesen: 44rem; /* Fließtext eines Eintrags */ --breite-lesen: 44rem; /* Fließtext eines Eintrags */
--breite-schmal: 25rem; /* Anmeldung */ --breite-schmal: 25rem; /* Anmeldung */
@ -198,7 +198,9 @@ pre code { background: none; border: none; padding: 0; }
font-size: var(--t-meta); font-size: var(--t-meta);
font-weight: 500; 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); background: var(--flaeche-leis);
color: var(--text); color: var(--text);
text-decoration: none; text-decoration: none;
@ -214,7 +216,7 @@ pre code { background: none; border: none; padding: 0; }
padding-inline: var(--s5); padding-inline: var(--s5);
display: grid; display: grid;
grid-template-columns: var(--breite-leiste) minmax(0, 1fr); grid-template-columns: var(--breite-leiste) minmax(0, 1fr);
gap: var(--s7); gap: var(--s6);
align-items: start; align-items: start;
} }
@ -635,13 +637,17 @@ table.tabelle {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 .karte { width: 100%; padding: var(--s6); }
.anmeldeseite .hilfe { display: block; margin-bottom: var(--s5); } .anmeldeseite .hilfe { display: block; margin-bottom: var(--s5); }
/* Logo zentriert über der Anmeldekarte. */ /* Logo zentriert über der Anmeldekarte. */
.anmelde-logo { display: flex; justify-content: center; margin-bottom: var(--s4); } .anmelde-logo { display: flex; justify-content: center; margin-bottom: var(--s2); }
.anmelde-logo img { width: 160px; height: auto; } /* 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 über der Eintragsliste: Suche und Filter. */
.werkzeugleiste { .werkzeugleiste {
@ -848,13 +854,16 @@ table.tabelle {
.graph-huelle svg { .graph-huelle svg {
display: block; display: block;
width: 100%; 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; cursor: grab;
touch-action: none; touch-action: none;
} }
.graph-huelle svg.graph-zieht { cursor: grabbing; } .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 { .graph-punkt {
fill: var(--akzent-flach); fill: var(--akzent-flach);
@ -873,12 +882,55 @@ table.tabelle {
/* Beschriftungen sollen das Ziehen des Graphen nicht durch /* Beschriftungen sollen das Ziehen des Graphen nicht durch
* Textmarkierung stören. */ * Textmarkierung stören. */
user-select: none; 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-knoten-fehlend .graph-beschriftung { fill: var(--text-leise); }
.graph-klickbar { cursor: pointer; } .graph-klickbar { cursor: pointer; }
.graph-klickbar:hover .graph-punkt { stroke-width: 2.5; } .graph-klickbar:hover .graph-punkt { stroke-width: 2.5; }
.graph-klickbar:hover .graph-beschriftung { fill: var(--akzent-text); } .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 /* Gefahrenzone: Löschvorgänge stehen abgesetzt am Seitenende, nicht zwischen
* den harmlosen Aktionen. Vorher steckte das Löschen eines Projekts samt * 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 * Bestätigungsfeld in einer Tabellenzelle — zu eng und viel zu beiläufig für

67
static/tagfilter.js Normal file
View file

@ -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();
})();

View file

@ -7,9 +7,17 @@
{{if .Project.Description}}{{.Project.Description}}{{else}}Wissen dieses Projekts — von Menschen und Agenten.{{end}} {{if .Project.Description}}{{.Project.Description}}{{else}}Wissen dieses Projekts — von Menschen und Agenten.{{end}}
</span> </span>
</div> </div>
{{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}} {{if .Session.CanWrite}}
<div class="seitenkopf-aktionen">
<a class="knopf" href="/p/{{.Project.Slug}}/new">Neuer Eintrag</a> <a class="knopf" href="/p/{{.Project.Slug}}/new">Neuer Eintrag</a>
{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>

View file

@ -6,6 +6,9 @@
<span class="hilfe">Verweise zwischen den Einträgen dieses Projekts. Gestrichelte Knoten sind <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> Ziele eines <code>[[…]]</code>-Verweises, zu denen es noch keinen Eintrag gibt.</span>
</div> </div>
<div class="seitenkopf-aktionen">
<button type="button" class="zweitrangig" data-graph-einpassen>Ganzen Graphen zeigen</button>
</div>
</div> </div>
<p class="hilfe" data-graph-hinweis hidden></p> <p class="hilfe" data-graph-hinweis hidden></p>
@ -15,6 +18,7 @@
role="img" aria-label="Verweisgraph"></svg> role="img" aria-label="Verweisgraph"></svg>
</div> </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" .}} {{template "fuss" .}}

View file

@ -9,7 +9,7 @@
<picture> wählt anhand der Systemeinstellung die passende aus. */}} <picture> wählt anhand der Systemeinstellung die passende aus. */}}
<picture class="anmelde-logo"> <picture class="anmelde-logo">
<source srcset="/static/img/munin_logo_dark.svg" media="(prefers-color-scheme: dark)"> <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> </picture>
<div class="karte"> <div class="karte">
<span class="hilfe">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin <span class="hilfe">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin

View file

@ -26,6 +26,7 @@
<script src="/static/preview.js"></script> <script src="/static/preview.js"></script>
<script src="/static/autocomplete.js"></script> <script src="/static/autocomplete.js"></script>
<script src="/static/graph.js"></script> <script src="/static/graph.js"></script>
<script src="/static/tagfilter.js"></script>
</body> </body>
</html> </html>
{{end}} {{end}}
@ -44,12 +45,19 @@
<div class="kopfleiste-innen"> <div class="kopfleiste-innen">
<div class="reihe"> <div class="reihe">
<a class="marke-wort" href="/">Munin</a> <a class="marke-wort" href="/">Munin</a>
{{if .Session.WorkspaceName}} {{if .Session.IsPlatformAdmin}}{{if .Session.WorkspaceName}}
<a class="arbeitsbereich" href="/admin/workspaces"> <a class="arbeitsbereich" href="/admin/workspaces">
<span class="arbeitsbereich-trenner" aria-hidden="true">/</span> <span class="arbeitsbereich-trenner" aria-hidden="true">/</span>
<span class="arbeitsbereich-name">{{.Session.WorkspaceName}}</span> <span class="arbeitsbereich-name">{{.Session.WorkspaceName}}</span>
<span class="arbeitsbereich-pfeil" aria-hidden="true">⌄</span> <span class="arbeitsbereich-pfeil" aria-hidden="true">⌄</span>
</a> </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}} {{end}}
</div> </div>
<form method="POST" action="/logout" class="reihe"> <form method="POST" action="/logout" class="reihe">
@ -66,7 +74,7 @@
über der Liste — sie sind Navigation, und die Liste bekommt so mehr Platz. */}} über der Liste — sie sind Navigation, und die Liste bekommt so mehr Platz. */}}
<aside class="seitenleiste"> <aside class="seitenleiste">
{{if .Session.IsPlatformAdmin}} {{if .Session.CanManageProjects}}
<nav class="nav-gruppe"> <nav class="nav-gruppe">
<h2 class="nav-titel">Projekte</h2> <h2 class="nav-titel">Projekte</h2>
<ul class="nav-liste"> <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> <li><a href="/p/{{.Project.Slug}}/graph" {{if eq .Bereich "graph"}}class="aktiv"{{end}}>Graph</a></li>
{{if .Session.CanManageKeys}} {{if .Session.CanManageKeys}}
<li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Bereich "zugaenge"}}class="aktiv"{{end}}>Zugänge</a></li> <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> <li><a href="/p/{{.Project.Slug}}/import" {{if eq .Bereich "import"}}class="aktiv"{{end}}>Import</a></li>
{{end}} {{end}}
<li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Bereich "nutzung"}}class="aktiv"{{end}}>Nutzung</a></li> <li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Bereich "nutzung"}}class="aktiv"{{end}}>Nutzung</a></li>
@ -116,6 +126,17 @@
</li> </li>
{{end}} {{end}}
</ul> </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> </nav>
{{end}} {{end}}
{{end}} {{end}}
@ -128,6 +149,13 @@
<li><a href="/admin/workspaces" {{if eq .Bereich "workspaces"}}class="aktiv"{{end}}>Workspaces</a></li> <li><a href="/admin/workspaces" {{if eq .Bereich "workspaces"}}class="aktiv"{{end}}>Workspaces</a></li>
</ul> </ul>
</nav> </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}} {{end}}
{{/* Die Hilfe steht für alle ganz unten — gleich, mit welcher Rolle {{/* Die Hilfe steht für alle ganz unten — gleich, mit welcher Rolle

View file

@ -32,6 +32,10 @@
<input type="text" id="slug" value="{{.Data.Item.Project.Slug}}" disabled> <input type="text" id="slug" value="{{.Data.Item.Project.Slug}}" disabled>
</div> </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"> <div class="feld kurz">
<label for="write_limit_hourly">Schreibbudget je Stunde</label> <label for="write_limit_hourly">Schreibbudget je Stunde</label>
<span class="hilfe"> <span class="hilfe">
@ -41,6 +45,15 @@
<input type="number" id="write_limit_hourly" name="write_limit_hourly" <input type="number" id="write_limit_hourly" name="write_limit_hourly"
min="0" step="1" value="{{.Data.Item.EigenesLimit}}"> min="0" step="1" value="{{.Data.Item.EigenesLimit}}">
</div> </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"> <div class="formular-aktionen">
<button type="submit">Speichern</button> <button type="submit">Speichern</button>

View file

@ -2,21 +2,27 @@
<div class="seitenkopf"> <div class="seitenkopf">
<div class="seitenkopf-titel"> <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 <span class="hilfe">Jedes Projekt hat eigene Einträge, eigene Zugänge und einen
eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht.</span> eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht.</span>
</div> </div>
{{if .ExportEnabled}} <div class="seitenkopf-aktionen reihe">
<div class="seitenkopf-aktionen"> {{/* Der Download geht an jeden, der diese Seite sieht (Plattform- und
{{/* Der Export läuft ohnehin täglich. Der Knopf ist für den Fall, dass Workspace-Admin), und braucht kein EXPORT_DIR. */}}
man unmittelbar vor einer Sicherung einen aktuellen Stand braucht. */}} <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"> <form method="POST" action="/admin/export" class="reihe">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="zweitrangig">Jetzt exportieren</button> <button type="submit" class="zweitrangig">Jetzt exportieren</button>
</form> </form>
</div>
{{end}} {{end}}
</div> </div>
</div>
{{if .Data.Projects}} {{if .Data.Projects}}
<div class="tabelle-huelle"> <div class="tabelle-huelle">

60
templates/tags.html Normal file
View file

@ -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" .}}

View file

@ -26,10 +26,95 @@
</form> </form>
</div> </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"> <section class="abschnitt">
<h2>Gefahrenzone</h2> <h2>Gefahrenzone</h2>
<span class="hilfe">Das Löschen entfernt alle {{.Data.Projects}} Projekte dieses Workspace samt <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> entfernt. Der Markdown-Export bleibt erhalten, sofern einer eingerichtet ist.</span>
<div class="karte gefahrenzone"> <div class="karte gefahrenzone">

View file

@ -15,6 +15,7 @@
<tr> <tr>
<th>Workspace</th> <th>Workspace</th>
<th class="zahl">Projekte</th> <th class="zahl">Projekte</th>
<th class="zahl">Workspace-Admins</th>
<th>Angelegt</th> <th>Angelegt</th>
<th class="schmal rechts">Auswahl</th> <th class="schmal rechts">Auswahl</th>
<th class="schmal rechts">Verwaltung</th> <th class="schmal rechts">Verwaltung</th>
@ -29,6 +30,16 @@
{{if eq .ID $aktuell}}<span class="marke betont">Aktuell</span>{{end}} {{if eq .ID $aktuell}}<span class="marke betont">Aktuell</span>{{end}}
</td> </td>
<td class="zahl">{{.Projects}}</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="hilfe">{{.CreatedAt}}</td>
<td class="schmal rechts"> <td class="schmal rechts">
{{if eq .ID $aktuell}} {{if eq .ID $aktuell}}