// Package web enthält die HTTP-Schicht: Routen, Middleware, Handler und die // Templates der Weboberfläche. package web import ( "context" "net/http" "time" "munin/internal/auth" "munin/internal/store" "munin/internal/workspace" ) // Session beschreibt, wer gerade zugreift. // // Es gibt drei Ausprägungen: // // - Plattform-Admin (ADMIN_TOKEN): darf Projekte anlegen und löschen und auf // jedes Projekt eines gewählten Workspace zugreifen. Key und Project sind // leer. WorkspaceID ist leer, solange noch kein Workspace gewählt wurde — // eine solche Sitzung darf ausschließlich die Workspace-Auswahl aufrufen // (siehe requireWorkspace in middleware.go). // - Workspace-Admin ("wa_…", AUSBAUSTUFEN.md 4.8): verwaltet Projekte und // Keys in genau einem Workspace und darf dessen Projekte lesen. Er darf // weder Einträge schreiben noch das Schreibbudget ändern, exportieren oder // andere Workspaces sehen. Key und Project sind leer. // - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem // Projekt in genau einem Workspace gehört. Was er darf, sagt Role. // // # Warum ein eigenes Feld statt IsPlatformAdmin mitzubenutzen? // // Jede bestehende Prüfung auf IsPlatformAdmin weist den Workspace-Admin damit // von selbst ab. Was er darf, ist unten an den Can…-Methoden einzeln // freigeschaltet — was dort nicht steht, darf er nicht. Vergisst man bei einer // künftigen Plattform-Funktion, an ihn zu denken, ist sie für ihn gesperrt, // nicht offen. type Session struct { Subject string // für die CSRF-Token-Berechnung Expires time.Time // Ablauf der Sitzung; ein Workspace-Wechsel übernimmt ihn (siehe setSessionCookie) IsPlatformAdmin bool IsWorkspaceAdmin bool WorkspaceID string WorkspaceName string // bei Plattform- und Workspace-Admin gefüllt, für die Kopfleiste WorkspaceAdmin store.WorkspaceAdmin Key store.APIKey Project store.Project Role auth.Role } // CanAccessProject sagt, ob diese Sitzung auf ein Projekt zugreifen darf. // // Hier steckt die Mandantentrennung der Weboberfläche: Ein Projekt-Key kommt // ausschließlich an sein eigenes Projekt, ganz gleich welche URL aufgerufen wird. // // Die beiden Admins kommen an jedes Projekt — aber nur an die ihres // Workspace: requireProject sucht das Projekt ausschließlich in der Datei des // Workspace, an den die Sitzung gebunden ist. Ein Projekt eines anderen // Workspace gibt es dort schlicht nicht. func (s Session) CanAccessProject(projectID string) bool { if s.IsPlatformAdmin || s.IsWorkspaceAdmin { return true } return s.Key.ProjectID == projectID } // CanWrite sagt, ob Einträge angelegt oder geändert werden dürfen. // // Der Workspace-Admin bewusst nicht: Einträge ändern die Teams über ihre // Keys, damit im Protokoll eindeutig steht, welcher Zugang etwas geschrieben hat. func (s Session) CanWrite() bool { return s.IsPlatformAdmin || s.Role.CanWrite() } // CanManageKeys sagt, ob die Keys eines Projekts verwaltet werden dürfen. func (s Session) CanManageKeys() bool { return s.IsPlatformAdmin || s.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. func (s Session) ActorName() string { if s.IsPlatformAdmin { return "platform-admin" } if s.IsWorkspaceAdmin { return "workspace-admin:" + s.WorkspaceAdmin.Name } return s.Key.Name } // contextKey ist ein eigener Typ für Context-Schlüssel. // // Ein eigener Typ (statt eines Strings) verhindert, dass zwei Pakete // versehentlich denselben Schlüssel verwenden und sich gegenseitig die Werte // überschreiben — der Typ ist hier nicht exportiert, also kann ihn niemand sonst // erzeugen. type contextKey int const ( sessionContextKey contextKey = iota projectContextKey actorContextKey workspaceContextKey ) // actorHolder ist ein veränderbarer Platz im Context, in den die // Anmelde-Middleware den Namen des Zugangs schreibt. // // # Warum dieser Umweg? // // Das Zugriffsprotokoll liegt ganz außen, damit es auch abgewiesene Anfragen // erfasst. Zu diesem Zeitpunkt ist aber noch niemand angemeldet — und die // Sitzung landet später in einer ABGELEITETEN Kopie des Requests // (r.WithContext), die das äußere Stück nie zu sehen bekommt. // // Deshalb legt die äußere Middleware einen leeren Halter ab, den die innere // befüllt. Ein Zeiger im Context ist der übliche Weg dafür. // // Ohne Absicherung gegen gleichzeitigen Zugriff: Geschrieben und gelesen wird in // derselben Goroutine, das Lesen erst nachdem der Handler zurückgekehrt ist. type actorHolder struct { name string } // withActorHolder legt einen leeren Halter im Context ab. func withActorHolder(ctx context.Context, h *actorHolder) context.Context { return context.WithValue(ctx, actorContextKey, h) } // recordActor trägt den Namen des Zugangs in den Halter ein, falls einer da ist. func recordActor(ctx context.Context, name string) { if h, ok := ctx.Value(actorContextKey).(*actorHolder); ok { h.name = name } } // withSession legt die Sitzung im Request-Context ab. // // Nebenbei wird der Name des Zugangs für das Zugriffsprotokoll vermerkt — das // liegt weiter außen und käme sonst nicht an ihn heran (siehe actorHolder). func withSession(ctx context.Context, s Session) context.Context { recordActor(ctx, s.ActorName()) return context.WithValue(ctx, sessionContextKey, s) } // SessionFrom liest die Sitzung aus dem Request. // Ohne durchlaufene Auth-Middleware ist ok false. func SessionFrom(r *http.Request) (Session, bool) { s, ok := r.Context().Value(sessionContextKey).(Session) return s, ok } // withProject legt das Projekt der aktuellen Route im Request-Context ab. func withProject(ctx context.Context, p store.Project) context.Context { return context.WithValue(ctx, projectContextKey, p) } // ProjectFrom liest das Projekt der aktuellen Route. func ProjectFrom(r *http.Request) (store.Project, bool) { p, ok := r.Context().Value(projectContextKey).(store.Project) return p, ok } // withWorkspace legt den aufgelösten Workspace (Datenbank + Suchindex) der // aktuellen Sitzung im Request-Context ab. // // Das übernimmt requireSession bzw. APIAuth — für Sitzungen ohne gewählten // Workspace (Plattform-Admin vor der Workspace-Auswahl) bleibt der Context // ohne Eintrag, siehe WorkspaceFrom. func withWorkspace(ctx context.Context, w *workspace.Workspace) context.Context { return context.WithValue(ctx, workspaceContextKey, w) } // WorkspaceFrom liest den Workspace der aktuellen Anfrage. // Ohne durchlaufene Auth-Middleware oder ohne gewählten Workspace ist ok false. func WorkspaceFrom(r *http.Request) (*workspace.Workspace, bool) { return WorkspaceFromContext(r.Context()) } // WorkspaceFromContext liest den Workspace direkt aus einem Context. // // Wie bei SessionFromContext: Die MCP-Werkzeuge bekommen vom SDK nur den // Context, keinen *http.Request. func WorkspaceFromContext(ctx context.Context) (*workspace.Workspace, bool) { w, ok := ctx.Value(workspaceContextKey).(*workspace.Workspace) return w, ok } // WithWorkspaceForTest legt einen Workspace im Context ab. // Siehe WithSessionForTest — derselbe Grund, derselbe Umweg. func WithWorkspaceForTest(ctx context.Context, w *workspace.Workspace) context.Context { return withWorkspace(ctx, w) } // SessionFromContext liest die Sitzung direkt aus einem Context. // // Die MCP-Werkzeuge bekommen vom SDK nur den Context übergeben, keinen // *http.Request — deshalb neben SessionFrom auch diese Variante. func SessionFromContext(ctx context.Context) (Session, bool) { s, ok := ctx.Value(sessionContextKey).(Session) return s, ok } // WithSessionForTest legt eine Sitzung im Context ab. // // Im Betrieb macht das die Middleware. Andere Packages (etwa die Tests von // internal/mcpserver) brauchen aber einen Weg, eine Sitzung zu setzen, ohne // einen kompletten HTTP-Server aufzubauen — und der Context-Schlüssel ist // bewusst nicht exportiert. func WithSessionForTest(ctx context.Context, s Session) context.Context { return withSession(ctx, s) }