299 lines
11 KiB
Go
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())
|
|
}
|
|
}
|