245 lines
9.3 KiB
Go
245 lines
9.3 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 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
|
|
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
|
|
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)
|
|
}
|