munin/internal/web/context.go
2026-10-19 10:13:56 +02:00

247 lines
9.4 KiB
Go

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