227 lines
7.5 KiB
Go
227 lines
7.5 KiB
Go
// 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:<name>" 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})
|
|
}
|