Files
nexarch/internal/tenantadmin/tenantadmin.go
T

299 lines
11 KiB
Go

// Package tenantadmin implementiert Core TEN-05: das Backend-API fuer die
// Tenant-Verwaltungsoberflaeche. Enthaelt KEINE eigene Provisioning-/
// Lifecycle-/Settings-Logik, sondern ist ein duenner Vermittler ueber
// internal/tenant (TEN-01/TEN-04), internal/tenantsettings (TEN-03) und
// internal/user.SuperadminStore (Berechtigungspruefung) — Ticket-Vorgabe:
// "Verwaltungsoberflaeche im Stil einer schlanken Zitadel-Console".
package tenantadmin
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ErrNotSuperadmin wird geliefert, wenn der Aufrufer keine aktive
// Superadmin-Identitaet hat (Akzeptanzkriterium 1 / Pruefung 1: die
// Oberflaeche zeigt Mandanten nur berechtigten Superadmins).
var ErrNotSuperadmin = errors.New("tenantadmin: aufrufer ist kein aktiver superadmin")
// ErrMissingDisplayName wird geliefert, wenn ein Einstellungs-Update ohne
// das Pflichtfeld Anzeigename versucht wird (Akzeptanzkriterium 2 / Pruefung 2).
var ErrMissingDisplayName = errors.New("tenantadmin: anzeigename ist ein pflichtfeld")
type Handler struct {
registry *tenant.Registry
lifecycle *tenant.Lifecycle
settings *tenantsettings.Store
superadmins *user.SuperadminStore
}
func NewHandler(registry *tenant.Registry, lifecycle *tenant.Lifecycle, settings *tenantsettings.Store, superadmins *user.SuperadminStore) *Handler {
return &Handler{registry: registry, lifecycle: lifecycle, settings: settings, superadmins: superadmins}
}
// requireSuperadmin prueft, dass der Aufrufer ein EXISTIERENDER, AKTIVER
// Superadmin ist — es gibt (bewusst, siehe internal/user.SuperadminStore)
// keine Tenant-Scoping-Dimension fuer Superadmins: wer ueberhaupt Zugriff
// hat, sieht alle Mandanten. Alles andere wird abgelehnt, bevor irgendeine
// Mandantendatei gelesen wird (Fail-Safe-Default).
func (h *Handler) requireSuperadmin(ctx context.Context, superadminID string) error {
if superadminID == "" {
return ErrNotSuperadmin
}
admin, err := h.superadmins.Get(ctx, superadminID)
if err != nil {
return ErrNotSuperadmin
}
if admin.Status != user.StatusActive {
return ErrNotSuperadmin
}
return nil
}
// TenantListItem ist die fuer die Uebersichtsliste relevante Projektion
// (Akzeptanzkriterium 1).
type TenantListItem struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
}
// ListTenants liefert alle Mandanten, optional gefiltert nach Suchbegriff
// (Slug/Name, Teilstring, case-insensitive) und Status — beides serverseitig,
// damit die Oberflaeche nicht selbst ueber unautorisierte Datensaetze
// filtern muss (Akzeptanzkriterium 1: Suche und Filter).
func (h *Handler) ListTenants(ctx context.Context, superadminID, search, statusFilter string) ([]TenantListItem, error) {
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
return nil, err
}
all, err := h.registry.List(ctx)
if err != nil {
return nil, fmt.Errorf("mandanten auflisten: %w", err)
}
search = strings.ToLower(strings.TrimSpace(search))
out := make([]TenantListItem, 0, len(all))
for _, t := range all {
if statusFilter != "" && string(t.Status) != statusFilter {
continue
}
if search != "" && !strings.Contains(strings.ToLower(t.Slug), search) && !strings.Contains(strings.ToLower(t.Name), search) {
continue
}
out = append(out, TenantListItem{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status)})
}
return out, nil
}
// TenantDetail buendelt Stammdaten und Einstellungen fuer die Detailansicht.
type TenantDetail struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
Settings tenantsettings.Settings `json:"settings"`
}
func (h *Handler) GetTenantDetail(ctx context.Context, superadminID, slug string) (TenantDetail, error) {
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
return TenantDetail{}, err
}
t, err := h.registry.GetBySlug(ctx, slug)
if err != nil {
return TenantDetail{}, fmt.Errorf("mandant laden: %w", err)
}
s, err := h.settings.Get(ctx, t.ID)
if err != nil {
return TenantDetail{}, fmt.Errorf("einstellungen laden: %w", err)
}
return TenantDetail{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status), Settings: s}, nil
}
// SettingsPatch ist die vom Formular gesendete Aenderung. DisplayName ist
// KEIN Zeiger, weil es Pflichtfeld ist (Akzeptanzkriterium 2) — die anderen
// Felder bleiben optional (Zeiger = "unveraendert lassen", siehe
// tenantsettings.Patch).
type SettingsPatch struct {
DisplayName string
LogoURL *string
ColorScheme *string
Timezone *string
Language *string
}
// UpdateSettings validiert das Pflichtfeld Anzeigename, BEVOR irgendein
// Schreibzugriff erfolgt (Akzeptanzkriterium 2 / Pruefung 2: unvollstaendige
// Pflichtfelder werden serverseitig verhindert, nicht nur clientseitig).
func (h *Handler) UpdateSettings(ctx context.Context, superadminID, slug string, patch SettingsPatch) (tenantsettings.Settings, error) {
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
return tenantsettings.Settings{}, err
}
if strings.TrimSpace(patch.DisplayName) == "" {
return tenantsettings.Settings{}, ErrMissingDisplayName
}
t, err := h.registry.GetBySlug(ctx, slug)
if err != nil {
return tenantsettings.Settings{}, fmt.Errorf("mandant laden: %w", err)
}
displayName := patch.DisplayName
return h.settings.Update(ctx, t.ID, tenantsettings.Patch{
DisplayName: &displayName,
LogoURL: patch.LogoURL,
ColorScheme: patch.ColorScheme,
Timezone: patch.Timezone,
Language: patch.Language,
})
}
// LifecycleAction sind die von der Oberflaeche ausloesbaren Aktionen
// (Akzeptanzkriterium 3) — je EIN Wort pro Aktion, damit ein Bestaetigungs-
// dialog im Frontend darauf verzweigen kann, ohne HTTP-Interna zu kennen.
type LifecycleAction string
const (
ActionSuspend LifecycleAction = "suspend"
ActionReactivate LifecycleAction = "reactivate"
ActionScheduleDeletion LifecycleAction = "schedule_deletion"
ActionCancelDeletion LifecycleAction = "cancel_deletion"
)
var ErrUnknownAction = errors.New("tenantadmin: unbekannte lifecycle-aktion")
// DefaultDeletionGracePeriod ist die Karenzzeit, die die Oberflaeche beim
// Ausloesen von ActionScheduleDeletion verwendet — Konfiguration dieses
// Pakets, nicht von internal/tenant (das lifecycle.go generisch mit einer
// uebergebenen Dauer arbeitet, siehe TEN-04).
const DefaultDeletionGracePeriod = 30 * 24 * time.Hour
// PerformLifecycleAction fuehrt EINEN der vier Uebergaenge aus. Ungueltige
// Zustandsuebergaenge (z.B. "suspend" auf einen bereits geloeschten Tenant)
// werden von internal/tenant.Lifecycle selbst mit ErrInvalidTransition
// abgelehnt (siehe TEN-04) — dieses Paket dupliziert diese Pruefung nicht.
func (h *Handler) PerformLifecycleAction(ctx context.Context, superadminID, slug string, action LifecycleAction) (tenant.Tenant, error) {
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
return tenant.Tenant{}, err
}
switch action {
case ActionSuspend:
return h.registry.Suspend(ctx, slug)
case ActionReactivate:
return h.registry.Reactivate(ctx, slug)
case ActionScheduleDeletion:
return h.registry.ScheduleDeletion(ctx, slug, DefaultDeletionGracePeriod)
case ActionCancelDeletion:
return h.registry.CancelDeletion(ctx, slug)
default:
return tenant.Tenant{}, ErrUnknownAction
}
}
// --- HTTP-Bindung ---
//
// Der Aufrufer wird bewusst als expliziter Query-/Body-Parameter
// "superadmin" statt aus einem Auth-Header gelesen — Session-/Token-basierte
// Authentifizierung ist Sache von IAM-01/IAM-02 und wird hier NICHT
// dupliziert (Kein Umbau angrenzender Bereiche); dieser Handler ist ein
// duenner Entwicklungs-/Testzugang, der genau die in requireSuperadmin
// beschriebene Berechtigungspruefung durchsetzt.
func (h *Handler) ListTenantsHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
items, err := h.ListTenants(r.Context(), q.Get("superadmin"), q.Get("search"), q.Get("status"))
writeResult(w, items, err)
}
func (h *Handler) TenantDetailHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
detail, err := h.GetTenantDetail(r.Context(), q.Get("superadmin"), q.Get("slug"))
writeResult(w, detail, err)
}
type settingsRequest struct {
Superadmin string `json:"superadmin"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
LogoURL *string `json:"logo_url"`
ColorScheme *string `json:"color_scheme"`
Timezone *string `json:"timezone"`
Language *string `json:"language"`
}
func (h *Handler) UpdateSettingsHandler(w http.ResponseWriter, r *http.Request) {
var req settingsRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return
}
result, err := h.UpdateSettings(r.Context(), req.Superadmin, req.Slug, SettingsPatch{
DisplayName: req.DisplayName,
LogoURL: req.LogoURL,
ColorScheme: req.ColorScheme,
Timezone: req.Timezone,
Language: req.Language,
})
writeResult(w, result, err)
}
type lifecycleRequest struct {
Superadmin string `json:"superadmin"`
Slug string `json:"slug"`
Action LifecycleAction `json:"action"`
}
func (h *Handler) LifecycleActionHandler(w http.ResponseWriter, r *http.Request) {
var req lifecycleRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return
}
result, err := h.PerformLifecycleAction(r.Context(), req.Superadmin, req.Slug, req.Action)
writeResult(w, result, err)
}
func decodeJSON(r *http.Request, v any) error {
return json.NewDecoder(r.Body).Decode(v)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeResult(w http.ResponseWriter, body any, err error) {
if err == nil {
writeJSON(w, http.StatusOK, body)
return
}
switch {
case errors.Is(err, ErrNotSuperadmin):
writeError(w, http.StatusForbidden, err.Error())
case errors.Is(err, ErrMissingDisplayName):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, ErrUnknownAction), errors.Is(err, tenant.ErrInvalidTransition):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, tenant.ErrTenantNotFound):
writeError(w, http.StatusNotFound, err.Error())
default:
writeError(w, http.StatusInternalServerError, err.Error())
}
}