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