munin/internal/web/context.go
2026-09-15 20:36:26 +02:00

147 lines
4.9 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"
)
// Session beschreibt, wer gerade zugreift.
//
// Es gibt zwei Ausprägungen:
//
// - Plattform-Admin (ADMIN_TOKEN): darf Projekte anlegen und löschen und auf
// jedes Projekt zugreifen. Key und Project sind leer.
// - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem
// Projekt gehört. Was er darf, sagt Role.
type Session struct {
Subject string // für die CSRF-Token-Berechnung
IsPlatformAdmin bool
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
)
// 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
}
// 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)
}