Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6, 5.1–5.4 (Details in AUSBAUSTUFEN.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
6.6 KiB
Go
185 lines
6.6 KiB
Go
// 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)
|
|
}
|