// Package web enthält die HTTP-Schicht: Routen, Middleware, Handler und die // Templates der Weboberfläche. package web import ( "context" "net/http" "munin/internal/auth" "munin/internal/store" "munin/internal/workspace" ) // Session beschreibt, wer gerade zugreift. // // Es gibt zwei 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). // - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem // Projekt in genau einem Workspace gehört. Was er darf, sagt Role. type Session struct { Subject string // für die CSRF-Token-Berechnung IsPlatformAdmin bool WorkspaceID string WorkspaceName string // nur beim Plattform-Admin gefüllt, für den Umschalter in der Kopfleiste Key store.APIKey Project store.Project Role auth.Role } // 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. func (s Session) CanAccessProject(projectID string) bool { if s.IsPlatformAdmin { return true } return s.Key.ProjectID == projectID } // CanWrite sagt, ob Einträge angelegt oder geändert werden dürfen. func (s Session) CanWrite() bool { return s.IsPlatformAdmin || s.Role.CanWrite() } // CanManageKeys sagt, ob die Keys eines Projekts verwaltet werden dürfen. func (s Session) CanManageKeys() bool { return s.IsPlatformAdmin || s.Role.CanManageKeys() } // ActorName ist der Name, unter dem diese Sitzung im Audit-Log auftaucht. func (s Session) ActorName() string { if s.IsPlatformAdmin { return "platform-admin" } 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 aktorContextKey workspaceContextKey ) // aktorHalter 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 aktorHalter struct { name string } // withAktorHalter legt einen leeren Halter im Context ab. func withAktorHalter(ctx context.Context, h *aktorHalter) context.Context { return context.WithValue(ctx, aktorContextKey, h) } // merkeAktor trägt den Namen des Zugangs in den Halter ein, falls einer da ist. func merkeAktor(ctx context.Context, name string) { if h, ok := ctx.Value(aktorContextKey).(*aktorHalter); 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 aktorHalter). func withSession(ctx context.Context, s Session) context.Context { merkeAktor(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) }