// Package adminapi implementiert Core LIC-04: das Backend-API fuer die // Lizenz-/Modul-Verwaltungsoberflaeche. Das Paket enthaelt KEINE eigene // Lizenz-/Flag-/Quota-Logik, sondern ist ein duenner Vermittler ueber // internal/license, internal/flag und internal/usage (Ticket-Vorgabe: // "Verwaltungsoberflaeche als duenner Client ... ohne eigene Lizenzlogik"). package adminapi import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "gitea.perlbach24.de/scripte/nexarch/internal/flag" "gitea.perlbach24.de/scripte/nexarch/internal/license" "gitea.perlbach24.de/scripte/nexarch/internal/usage" ) // UsageMetrics sind die Metriken, die auf der Uebersichtsseite je Tenant // dargestellt werden (Akzeptanzkriterium 3). Neue Metriken hinzuzufuegen // beruehrt kein bestehendes Verhalten von internal/usage. var UsageMetrics = []string{"users", "storage_bytes", "api_calls"} // FlagOverview ist ein Flag zusammen mit der fuer den Tenant ausgewerteten // Berechtigung, es umzuschalten (Akzeptanzkriterium 2). type FlagOverview struct { Key string `json:"key"` Enabled bool `json:"enabled"` Licensed bool `json:"licensed"` DenyGrund string `json:"deny_grund,omitempty"` } // UsageOverview ist eine Metrik mit Zaehlerstand, Quota und Status // (Akzeptanzkriterium 3). type UsageOverview struct { Metric string `json:"metric"` Value int64 `json:"value"` Limit int64 `json:"limit"` Status usage.Status `json:"status"` } // Overview ist die vollstaendige Antwort fuer die Uebersichtsseite // (Akzeptanzkriterium 1: Lizenzstatus/Laufzeit/Module auf einen Blick). type Overview struct { Plan string `json:"plan"` Modules []string `json:"modules"` IssuedAt string `json:"issued_at"` ValidUntil string `json:"valid_until"` Expired bool `json:"expired"` Flags []FlagOverview `json:"flags"` Usage []UsageOverview `json:"usage"` } // ErrFlagNotLicensed wird geliefert, wenn ein Administrator versucht, ein // Flag zu aktivieren, dessen zugehoeriges Modul die Lizenz nicht umfasst // (Akzeptanzkriterium 2 / Pruefung 1). var ErrFlagNotLicensed = errors.New("adminapi: modul ist durch die aktuelle lizenz nicht freigeschaltet") // Handler buendelt die drei Vorbedingungs-Dienste hinter einem gemeinsamen // HTTP-API fuer das Frontend. type Handler struct { licenseStore *license.Store flagStore *flag.Store flagService *flag.Service usageStore *usage.Store } func NewHandler(licenseStore *license.Store, flagStore *flag.Store, flagService *flag.Service, usageStore *usage.Store) *Handler { return &Handler{licenseStore: licenseStore, flagStore: flagStore, flagService: flagService, usageStore: usageStore} } // isModuleLicensed prueft, ob ein Flag-Schluessel als lizenziertes Modul // gilt. Konvention: ein Flag-Key der Form "module:" ist an das // gleichnamige Modul in Payload.Modules gebunden; alle anderen Flag-Keys // gelten als lizenzunabhaengige, rein funktionale Schalter. func isModuleLicensed(key string, modules []string) (moduleName string, isModuleFlag bool, licensed bool) { const prefix = "module:" if !strings.HasPrefix(key, prefix) { return "", false, true } moduleName = strings.TrimPrefix(key, prefix) for _, m := range modules { if m == moduleName { return moduleName, true, true } } return moduleName, true, false } // Overview liefert die kombinierte Uebersicht fuer einen Tenant // (Akzeptanzkriterium 1 + 3). func (h *Handler) Overview(ctx context.Context, tenantID string) (Overview, error) { lic, err := h.licenseStore.Status(ctx, tenantID) if err != nil { return Overview{}, fmt.Errorf("lizenzstatus lesen: %w", err) } _, expiredErr := h.licenseStore.RequireActive(ctx, tenantID) expired := errors.Is(expiredErr, license.ErrLicenseExpired) flags, err := h.flagStore.List(ctx) if err != nil { return Overview{}, fmt.Errorf("flags lesen: %w", err) } tenantSlug := tenantID // Auswertung erwartet den Tenant-Bezug als String; Aufloesung ID->Slug obliegt dem Aufrufer (siehe Handler-Tests). flagOverviews := make([]FlagOverview, 0, len(flags)) for _, f := range flags { _, _, licensed := isModuleLicensed(f.Key, lic.Modules) fo := FlagOverview{ Key: f.Key, Enabled: h.flagService.IsEnabled(ctx, tenantSlug, f.Key), Licensed: licensed, } if !licensed { fo.DenyGrund = "Modul ist im aktuellen Lizenzplan nicht enthalten" } flagOverviews = append(flagOverviews, fo) } usageOverviews := make([]UsageOverview, 0, len(UsageMetrics)) for _, metric := range UsageMetrics { value, limit, status, err := h.usageStore.Check(ctx, tenantID, metric) if err != nil { return Overview{}, fmt.Errorf("nutzungsstand lesen (%s): %w", metric, err) } usageOverviews = append(usageOverviews, UsageOverview{ Metric: metric, Value: value, Limit: limit, Status: status, }) } return Overview{ Plan: lic.Plan, Modules: lic.Modules, IssuedAt: lic.IssuedAt.Format("2006-01-02T15:04:05Z07:00"), ValidUntil: lic.ValidUntil.Format("2006-01-02T15:04:05Z07:00"), Expired: expired, Flags: flagOverviews, Usage: usageOverviews, }, nil } // ToggleFlag schaltet ein Flag global an/aus, NACHDEM geprueft wurde, dass // das zugehoerige Modul lizenziert ist (Akzeptanzkriterium 2 / Pruefung 1). // Ein nicht lizenziertes Modul wird mit ErrFlagNotLicensed abgelehnt, bevor // irgendein Schreibzugriff erfolgt. func (h *Handler) ToggleFlag(ctx context.Context, tenantID, key string, enabled bool) error { lic, err := h.licenseStore.Status(ctx, tenantID) if err != nil { return fmt.Errorf("lizenzstatus lesen: %w", err) } if enabled { if _, _, licensed := isModuleLicensed(key, lic.Modules); !licensed { return ErrFlagNotLicensed } } existing, err := h.flagStore.Get(ctx, key) if err != nil && !errors.Is(err, flag.ErrNotFound) { return fmt.Errorf("flag lesen: %w", err) } existing.Key = key existing.Enabled = enabled if err := h.flagStore.Set(ctx, existing); err != nil { return fmt.Errorf("flag speichern: %w", err) } h.flagService.Invalidate(key) return nil } // --- HTTP-Bindung --- func (h *Handler) OverviewHandler(w http.ResponseWriter, r *http.Request) { tenantID := r.URL.Query().Get("tenant") if tenantID == "" { writeError(w, http.StatusBadRequest, "tenant-parameter fehlt") return } overview, err := h.Overview(r.Context(), tenantID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, overview) } type toggleRequest struct { Tenant string `json:"tenant"` Key string `json:"key"` Enabled bool `json:"enabled"` } func (h *Handler) ToggleFlagHandler(w http.ResponseWriter, r *http.Request) { var req toggleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") return } if req.Tenant == "" || req.Key == "" { writeError(w, http.StatusBadRequest, "tenant und key sind erforderlich") return } err := h.ToggleFlag(r.Context(), req.Tenant, req.Key, req.Enabled) switch { case err == nil: writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) case errors.Is(err, ErrFlagNotLicensed): writeError(w, http.StatusForbidden, ErrFlagNotLicensed.Error()) default: writeError(w, http.StatusInternalServerError, err.Error()) } } 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}) }