From 6b4274d12be6c6d1982ce708701098c0c8c67ccb Mon Sep 17 00:00:00 2001 From: Nuxgrid Date: Mon, 27 Jul 2026 16:06:19 +0200 Subject: [PATCH] feat: add api --- control-plane/api/.dockerignore | 6 + control-plane/api/.env.example | 30 ++ control-plane/api/Dockerfile | 20 ++ control-plane/api/cmd/api/main.go | 167 +++++++++ control-plane/api/go.mod | 80 +++++ control-plane/api/go.sum | 195 ++++++++++ control-plane/api/internal/auth/gormstore.go | 56 +++ control-plane/api/internal/auth/handler.go | 176 +++++++++ control-plane/api/internal/auth/jwt.go | 63 ++++ control-plane/api/internal/auth/middleware.go | 81 +++++ control-plane/api/internal/auth/models.go | 14 + control-plane/api/internal/auth/password.go | 61 ++++ control-plane/api/internal/auth/role.go | 9 + control-plane/api/internal/auth/utils.go | 33 ++ control-plane/api/internal/config/config.go | 81 +++++ control-plane/api/internal/db/db.go | 55 +++ control-plane/api/internal/demos/demos.go | 96 +++++ control-plane/api/internal/demos/handler.go | 104 ++++++ .../api/internal/demos/helm_provisioner.go | 337 ++++++++++++++++++ control-plane/api/internal/demos/mem.go | 145 ++++++++ control-plane/api/internal/demos/pool.go | 127 +++++++ .../api/internal/demos/provisioner.go | 18 + control-plane/api/internal/demos/service.go | 172 +++++++++ control-plane/api/internal/demos/store.go | 70 ++++ control-plane/api/internal/httpx/security.go | 81 +++++ control-plane/api/internal/k8s/client.go | 34 ++ control-plane/api/internal/leads/gormstore.go | 57 +++ control-plane/api/internal/leads/handler.go | 77 ++++ control-plane/api/internal/leads/leads.go | 96 +++++ control-plane/api/internal/router/router.go | 86 +++++ control-plane/api/internal/sav/handler.go | 60 ++++ control-plane/api/internal/sav/mem.go | 49 +++ control-plane/api/internal/sav/models.go | 12 + control-plane/api/internal/sav/store.go | 43 +++ control-plane/api/internal/session/mem.go | 54 +++ control-plane/api/internal/session/redis.go | 65 ++++ control-plane/api/internal/session/session.go | 41 +++ control-plane/api/internal/sub/handler.go | 126 +++++++ control-plane/api/internal/sub/mem.go | 56 +++ control-plane/api/internal/sub/models.go | 17 + control-plane/api/internal/sub/store.go | 176 +++++++++ control-plane/api/internal/test/auth_test.go | 93 +++++ control-plane/api/internal/test/demos_test.go | 187 ++++++++++ .../api/internal/test/helpers_test.go | 111 ++++++ control-plane/api/internal/test/http_test.go | 142 ++++++++ .../api/internal/test/integration_test.go | 63 ++++ 46 files changed, 3922 insertions(+) create mode 100644 control-plane/api/.dockerignore create mode 100644 control-plane/api/.env.example create mode 100644 control-plane/api/Dockerfile create mode 100644 control-plane/api/cmd/api/main.go create mode 100644 control-plane/api/go.mod create mode 100644 control-plane/api/go.sum create mode 100644 control-plane/api/internal/auth/gormstore.go create mode 100644 control-plane/api/internal/auth/handler.go create mode 100644 control-plane/api/internal/auth/jwt.go create mode 100644 control-plane/api/internal/auth/middleware.go create mode 100644 control-plane/api/internal/auth/models.go create mode 100644 control-plane/api/internal/auth/password.go create mode 100644 control-plane/api/internal/auth/role.go create mode 100644 control-plane/api/internal/auth/utils.go create mode 100644 control-plane/api/internal/config/config.go create mode 100644 control-plane/api/internal/db/db.go create mode 100644 control-plane/api/internal/demos/demos.go create mode 100644 control-plane/api/internal/demos/handler.go create mode 100644 control-plane/api/internal/demos/helm_provisioner.go create mode 100644 control-plane/api/internal/demos/mem.go create mode 100644 control-plane/api/internal/demos/pool.go create mode 100644 control-plane/api/internal/demos/provisioner.go create mode 100644 control-plane/api/internal/demos/service.go create mode 100644 control-plane/api/internal/demos/store.go create mode 100644 control-plane/api/internal/httpx/security.go create mode 100644 control-plane/api/internal/k8s/client.go create mode 100644 control-plane/api/internal/leads/gormstore.go create mode 100644 control-plane/api/internal/leads/handler.go create mode 100644 control-plane/api/internal/leads/leads.go create mode 100644 control-plane/api/internal/router/router.go create mode 100644 control-plane/api/internal/sav/handler.go create mode 100644 control-plane/api/internal/sav/mem.go create mode 100644 control-plane/api/internal/sav/models.go create mode 100644 control-plane/api/internal/sav/store.go create mode 100644 control-plane/api/internal/session/mem.go create mode 100644 control-plane/api/internal/session/redis.go create mode 100644 control-plane/api/internal/session/session.go create mode 100644 control-plane/api/internal/sub/handler.go create mode 100644 control-plane/api/internal/sub/mem.go create mode 100644 control-plane/api/internal/sub/models.go create mode 100644 control-plane/api/internal/sub/store.go create mode 100644 control-plane/api/internal/test/auth_test.go create mode 100644 control-plane/api/internal/test/demos_test.go create mode 100644 control-plane/api/internal/test/helpers_test.go create mode 100644 control-plane/api/internal/test/http_test.go create mode 100644 control-plane/api/internal/test/integration_test.go diff --git a/control-plane/api/.dockerignore b/control-plane/api/.dockerignore new file mode 100644 index 0000000..67b1f42 --- /dev/null +++ b/control-plane/api/.dockerignore @@ -0,0 +1,6 @@ +.env +.env.* +*_test.go +test/ +Dockerfile +.dockerignore diff --git a/control-plane/api/.env.example b/control-plane/api/.env.example new file mode 100644 index 0000000..0cbe927 --- /dev/null +++ b/control-plane/api/.env.example @@ -0,0 +1,30 @@ +# === Omnex control-plane API — variables d'environnement === +# Copier en .env (jamais versionné) et compléter. + +# Obligatoire : secret de signature JWT (min 32 octets) +OMNEX_JWT_SECRET=change-me-32-bytes-minimum-secret!! + +# Adresse d'écoute +OMNEX_ADDR=:8080 + +# "dev" | "prod" (en prod, OMNEX_DATABASE_URL et OMNEX_REDIS_URL deviennent obligatoires) +OMNEX_ENV=dev + +# CORS : origines autorisées (vitrine + back-office), séparées par des virgules +OMNEX_ALLOWED_ORIGINS=http://localhost:5173 + +# PostgreSQL (GORM). Vide en dev => store mémoire volatil. +# OMNEX_DATABASE_URL=host=localhost user=omnex password=omnex dbname=omnex port=5432 sslmode=disable + +# Redis (sessions). Vide en dev => sessions en mémoire volatile. +# OMNEX_REDIS_URL=redis://:omnexredis@localhost:6379/0 + +# Seed de l'utilisateur admin de départ (optionnel) — nom d'utilisateur, pas email +OMNEX_SEED_USERNAME=admin +OMNEX_SEED_PASSWORD=change-me-strong-password + +# Domaine des démos déployées dans le cluster +OMNEX_DEMO_DOMAIN=demo.omnex.app + +# Tests d'intégration GORM (optionnel) — base jetable +# OMNEX_TEST_DATABASE_URL=host=localhost user=omnex password=omnex dbname=omnex_test port=5432 sslmode=disable diff --git a/control-plane/api/Dockerfile b/control-plane/api/Dockerfile new file mode 100644 index 0000000..509ae8d --- /dev/null +++ b/control-plane/api/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates wget curl \ + && wget -q https://get.helm.sh/helm-v3.16.1-linux-amd64.tar.gz -O /tmp/helm.tar.gz \ + && tar -xzf /tmp/helm.tar.gz -C /tmp \ + && mv /tmp/linux-amd64/helm /usr/local/bin/helm \ + && rm -rf /tmp/helm.tar.gz /tmp/linux-amd64 \ + && adduser -D -u 10001 omnex +COPY --from=build /out/api /api +USER omnex +EXPOSE 8080 +HEALTHCHECK --interval=10s --timeout=3s --retries=5 \ + CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["/api"] diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go new file mode 100644 index 0000000..0b7105e --- /dev/null +++ b/control-plane/api/cmd/api/main.go @@ -0,0 +1,167 @@ +// Commande api : point d'entrée du control-plane Omnex. +package main + +import ( + "context" + "log" + "os" + "strings" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "github.com/omnex/control-plane/api/internal/auth" + "github.com/omnex/control-plane/api/internal/config" + "github.com/omnex/control-plane/api/internal/db" + "github.com/omnex/control-plane/api/internal/demos" + "github.com/omnex/control-plane/api/internal/k8s" + "github.com/omnex/control-plane/api/internal/leads" + "github.com/omnex/control-plane/api/internal/router" + "github.com/omnex/control-plane/api/internal/sav" + "github.com/omnex/control-plane/api/internal/session" + "github.com/omnex/control-plane/api/internal/sub" +) + +// memUsers : store utilisateurs en mémoire (fallback dev sans base). +type memUsers map[string]auth.User + +func (m memUsers) ByUsername(username string) (auth.User, bool) { + u, ok := m[strings.ToLower(strings.TrimSpace(username))] + return u, ok +} + +func (m memUsers) Create(username, passwordHash string, role auth.Role) (auth.User, error) { + key := strings.ToLower(strings.TrimSpace(username)) + u := auth.User{ID: uuid.NewString(), Username: key, PasswordHash: passwordHash, Role: role} + m[key] = u + return u, nil +} + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + iss := auth.NewIssuer(cfg.JWTSecret, cfg.SessionTTL) + + // Sessions : Redis en prod/dev configuré, mémoire en fallback dev. + var sessions session.Manager + if cfg.RedisURL != "" { + opt, err := redis.ParseURL(cfg.RedisURL) + if err != nil { + log.Fatalf("redis url: %v", err) + } + rdb := redis.NewClient(opt) + if err := rdb.Ping(context.Background()).Err(); err != nil { + log.Fatalf("redis ping: %v", err) + } + sessions = session.NewRedisManager(rdb, cfg.SessionTTL) + log.Printf("sessions: Redis") + } else { + sessions = session.NewMemManager(cfg.SessionTTL) + log.Printf("sessions: mémoire (dev — définir OMNEX_REDIS_URL pour Redis)") + } + + var userStore auth.UserStore + var leadStore leads.Store + var demoStore demos.Store + var demoPool demos.Pool + var codeStore sub.Store + var contactStore sav.Store + if cfg.DatabaseURL != "" { + gdb, err := db.Open(cfg.DatabaseURL) + if err != nil { + log.Fatalf("db: %v", err) + } + if err := db.AutoMigrate(gdb); err != nil { + log.Fatalf("migrate: %v", err) + } + if err := db.SeedExternalPool(gdb); err != nil { + log.Fatalf("seed pool: %v", err) + } + users := auth.NewGormStore(gdb) + seedAdmin(users) + userStore = users + codeStore = sub.NewGormStore(gdb) + leadStore = leads.NewGormStore(gdb) + demoStore = demos.NewGormStore(gdb) + demoPool = demos.NewGormPool(gdb) + contactStore = sav.NewGormStore(gdb) + log.Printf("persistance: PostgreSQL (GORM)") + } else { + userStore = seedMemUsers() + codeStore = sub.NewMemStore() + leadStore = leads.NewMemStore() + demoStore = demos.NewMemStore() + demoPool = demos.NewMemPool() + contactStore = sav.NewMemContactStore() + log.Printf("persistance: mémoire (dev — définir OMNEX_DATABASE_URL pour PostgreSQL)") + } + + // Créer le client Kubernetes + k8sClient, err := k8s.NewClient(&cfg) + if err != nil { + log.Fatalf("k8s client: %v", err) + } + + helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, "/charts", "helm") + if err != nil { + log.Fatalf("helm provisioner: %v", err) + } + + // Service de démos avec le provisioner Helm + demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{ + BaseDomain: cfg.DemoDomain, + TTL: demos.TTL, + MaxDemos: demos.MaxConcurrentDemos, + }) + + deps := router.Deps{ + Cfg: cfg, + Issuer: iss, + Sessions: sessions, + AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()), + LeadsH: leads.NewHandler(leadStore), + DemosH: demos.NewHandler(demoSvc), + SubH: sub.NewHandler(codeStore), + ContactH: sav.NewHandler(contactStore), + } + + r := router.New(deps) + log.Printf("Omnex API sur %s (env=%s)", cfg.Addr, cfg.Env) + if err := r.Run(cfg.Addr); err != nil { + log.Fatal(err) + } +} + +// seedAdmin crée l'utilisateur admin de départ depuis l'env (idempotent). +func seedAdmin(users *auth.GormStore) { + username, pass := os.Getenv("OMNEX_SEED_USERNAME"), os.Getenv("OMNEX_SEED_PASSWORD") + if username == "" || pass == "" { + return + } + hash, err := auth.HashPassword(pass) + if err != nil { + log.Fatalf("seed: %v", err) + } + if err := users.EnsureUser(username, hash, auth.RoleAdmin); err != nil { + log.Fatalf("seed: %v", err) + } + log.Printf("utilisateur admin seedé: %s", username) +} + +// seedMemUsers construit un store mémoire depuis l'env (dev uniquement). +func seedMemUsers() memUsers { + users := memUsers{} + if username, pass := os.Getenv("OMNEX_SEED_USERNAME"), os.Getenv("OMNEX_SEED_PASSWORD"); username != "" && pass != "" { + hash, err := auth.HashPassword(pass) + if err != nil { + log.Fatalf("seed: %v", err) + } + key := strings.ToLower(strings.TrimSpace(username)) + users[key] = auth.User{ID: "seed-admin", Username: key, PasswordHash: hash, Role: auth.RoleAdmin} + log.Printf("utilisateur admin seedé (mémoire): %s", key) + } + return users +} diff --git a/control-plane/api/go.mod b/control-plane/api/go.mod new file mode 100644 index 0000000..fca873d --- /dev/null +++ b/control-plane/api/go.mod @@ -0,0 +1,80 @@ +module github.com/omnex/control-plane/api + +go 1.26.0 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.7.0 + go.yaml.in/yaml/v2 v2.4.3 + golang.org/x/crypto v0.47.0 + golang.org/x/time v0.14.0 + gorm.io/driver/postgres v1.5.9 + gorm.io/gorm v1.25.12 + k8s.io/api v0.36.3 + k8s.io/apimachinery v0.36.3 + k8s.io/client-go v0.36.3 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/control-plane/api/go.sum b/control-plane/api/go.sum new file mode 100644 index 0000000..8dfa045 --- /dev/null +++ b/control-plane/api/go.sum @@ -0,0 +1,195 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= +gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= +k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= +k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= +k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= +k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= +k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw= +sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/control-plane/api/internal/auth/gormstore.go b/control-plane/api/internal/auth/gormstore.go new file mode 100644 index 0000000..2ed88aa --- /dev/null +++ b/control-plane/api/internal/auth/gormstore.go @@ -0,0 +1,56 @@ +package auth + +import ( + "errors" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// GormStore : UserStore adossé à PostgreSQL via GORM. +type GormStore struct { + db *gorm.DB +} + +func NewGormStore(db *gorm.DB) *GormStore { + return &GormStore{db: db} +} + +// ByUsername cherche un utilisateur (requête paramétrée : anti-injection). +func (s *GormStore) ByUsername(username string) (User, bool) { + var u User + err := s.db.Where("username = ?", NormalizeUsername(username)).First(&u).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return User{}, false + } + if err != nil { + return User{}, false + } + return u, true +} + +// Create insère un utilisateur (usage seed/admin), mot de passe déjà haché. +func (s *GormStore) Create(username, passwordHash string, role Role) (User, error) { + u := User{ + ID: uuid.NewString(), + Username: NormalizeUsername(username), + PasswordHash: passwordHash, + Role: role, + } + if role == RoleAdmin { + u.TypeAbo = "admin" + } + if err := s.db.Create(&u).Error; err != nil { + return User{}, err + } + return u, nil +} + +// EnsureUser crée l'utilisateur s'il n'existe pas encore (idempotent, pour le seed). +func (s *GormStore) EnsureUser(username, passwordHash string, role Role) error { + if _, found := s.ByUsername(username); found { + return nil + } + _, err := s.Create(username, passwordHash, role) + return err +} diff --git a/control-plane/api/internal/auth/handler.go b/control-plane/api/internal/auth/handler.go new file mode 100644 index 0000000..027616c --- /dev/null +++ b/control-plane/api/internal/auth/handler.go @@ -0,0 +1,176 @@ +package auth + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/omnex/control-plane/api/internal/session" +) + +func (User) TableName() string { return "users" } + +type UserStore interface { + ByUsername(username string) (User, bool) + Create(username, passwordHash string, role Role) (User, error) +} + +type Handler struct { + users UserStore + sessions session.Manager + iss *Issuer + secure bool // cookie Secure (activé en prod/HTTPS) +} + +func NewHandler(users UserStore, sessions session.Manager, iss *Issuer, secure bool) *Handler { + return &Handler{users: users, sessions: sessions, iss: iss, secure: secure} +} + +type loginRequest struct { + Username string + Password string +} + +// Login authentifie, ouvre une session Redis et pose le cookie httpOnly. +// Réponse volontairement uniforme (pas d'énumération d'utilisateurs). +func (h *Handler) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) + return + } + + user, found := h.users.ByUsername(req.Username) + if !found { + // On hache quand même une valeur bidon pour égaliser le temps de réponse. + _, _ = VerifyPassword(req.Password, dummyHash) + c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"}) + return + } + + ok, err := VerifyPassword(req.Password, user.PasswordHash) + if err != nil || !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"}) + return + } + + token, err := h.startSession(c, user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + + c.JSON(http.StatusOK, gin.H{"token": token, "token_type": "Bearer", "role": user.Role}) +} + +// Me renvoie l'identité de la session courante (dont le rôle, pour le front). +func (h *Handler) Me(c *gin.Context) { + p := PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) + return + } + + user, found := h.users.ByUsername(p.Username) + if !found { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) + return + } + + typeAbo := user.TypeAbo + if typeAbo == "premium" && time.Now().UTC().After(user.ExpiredAt) { + typeAbo = "demo" // expiré : on ne le renvoie plus comme premium + } + + c.JSON(http.StatusOK, gin.H{ + "user_id": p.UserID, + "username": p.Username, + "role": p.Role, + "type_abonnement": typeAbo, + "expired_at": user.ExpiredAt, + }) +} + +type registerRequest struct { + Username string + Password string +} + +// Register crée un compte commercial puis ouvre directement la session. +func (h *Handler) Register(c *gin.Context) { + var req registerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) + return + } + + if _, exists := h.users.ByUsername(req.Username); exists { + c.JSON(http.StatusConflict, gin.H{"error": "nom d'utilisateur déjà pris"}) + return + } + + hash, err := HashPassword(req.Password) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + user, err := h.users.Create(req.Username, hash, RoleClient) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "nom d'utilisateur déjà pris"}) + return + } + + token, err := h.startSession(c, user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + c.JSON(http.StatusCreated, gin.H{"token": token, "token_type": "Bearer", "role": user.Role}) +} + +// startSession ouvre une session Redis, signe le JWT et pose le cookie. +func (h *Handler) startSession(c *gin.Context, user User) (string, error) { + sid, err := h.sessions.Create(c.Request.Context(), session.Session{ + UserID: user.ID, + Role: string(user.Role), + }) + if err != nil { + return "", err + } + token, err := h.iss.Issue(user.ID, user.Username, user.Role, sid) + if err != nil { + _ = h.sessions.Delete(c.Request.Context(), sid) // fail-secure + return "", err + } + h.setSessionCookie(c, token, int(h.sessions.TTL().Seconds())) + return token, nil +} + +// Logout révoque la session Redis courante et efface le cookie. +func (h *Handler) Logout(c *gin.Context) { + if p := PrincipalFrom(c); p != nil { + if err := h.sessions.Delete(c.Request.Context(), p.SessionID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + } + h.setSessionCookie(c, "", -1) + c.JSON(http.StatusOK, gin.H{"status": "déconnecté"}) +} + +func (h *Handler) setSessionCookie(c *gin.Context, value string, maxAge int) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: session.CookieName, + Value: value, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: h.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// dummyHash : argon2id d'une valeur arbitraire, pour la mitigation de timing. +const dummyHash = "=19=65536,t=1,p=4$" + + "3Vk0m5m8c1m6l0k9j8h7g6f5d4s3a2z1x0c9v8b7n6m" diff --git a/control-plane/api/internal/auth/jwt.go b/control-plane/api/internal/auth/jwt.go new file mode 100644 index 0000000..a872782 --- /dev/null +++ b/control-plane/api/internal/auth/jwt.go @@ -0,0 +1,63 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ErrInvalidToken = errors.New("token invalide") + +// Claims du token Omnex : porte le rôle et l'identifiant de session Redis (sid). +// La signature (secret) authentifie le token ; le sid permet la révocation. +type Claims struct { + Role Role `json:"role"` + Username string `json:"username"` + SessionID string `json:"sid"` + jwt.RegisteredClaims +} + +// Issuer signe et vérifie des JWT HS256. +type Issuer struct { + secret []byte + ttl time.Duration +} + +func NewIssuer(secret []byte, ttl time.Duration) *Issuer { + return &Issuer{secret: secret, ttl: ttl} +} + +// Issue crée un token pour un utilisateur, lié à une session Redis (sid). +func (i *Issuer) Issue(userID string, username string, role Role, sid string) (string, error) { + now := time.Now() + claims := Claims{ + Role: role, + SessionID: sid, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)), + NotBefore: jwt.NewNumericDate(now), + Issuer: "omnex", + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret) +} + +// Verify valide la signature + l'expiration et renvoie les claims. +func (i *Issuer) Verify(tokenStr string) (*Claims, error) { + claims := &Claims{} + _, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) { + // Refuse tout algo autre que HS256 (protection alg-confusion). + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } + return i.secret, nil + }) + if err != nil { + return nil, ErrInvalidToken + } + return claims, nil +} diff --git a/control-plane/api/internal/auth/middleware.go b/control-plane/api/internal/auth/middleware.go new file mode 100644 index 0000000..a24fead --- /dev/null +++ b/control-plane/api/internal/auth/middleware.go @@ -0,0 +1,81 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/omnex/control-plane/api/internal/session" +) + +const ctxPrincipalKey = "omnex.principal" + +// Principal : identité authentifiée injectée dans le contexte de requête. +type Principal struct { + UserID string + Username string + Role Role + SessionID string +} + +// tokenFromRequest lit le JWT : cookie httpOnly en priorité, sinon Bearer. +func tokenFromRequest(c *gin.Context) string { + if ck, err := c.Request.Cookie(session.CookieName); err == nil && ck.Value != "" { + return ck.Value + } + if t, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer "); ok { + return t + } + return "" +} + +// RequireAuth valide le JWT (signature) PUIS l'existence de la session Redis. +// Un logout supprime la session : le JWT devient alors invalide avant expiration. +func RequireAuth(iss *Issuer, mgr session.Manager) gin.HandlerFunc { + return func(c *gin.Context) { + raw := tokenFromRequest(c) + if raw == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentification requise"}) + return + } + claims, err := iss.Verify(raw) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token invalide"}) + return + } + _, found, err := mgr.Get(c.Request.Context(), claims.SessionID) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + if !found { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expirée ou révoquée"}) + return + } + c.Set(ctxPrincipalKey, Principal{UserID: claims.Subject, Username: claims.Username, Role: claims.Role, SessionID: claims.SessionID}) + c.Next() + } +} + +// RequireRole impose un rôle minimal (admin > rôle demandé). +func RequireRole(role Role) gin.HandlerFunc { + return func(c *gin.Context) { + p := PrincipalFrom(c) + if p == nil || (p.Role != role && p.Role != RoleAdmin) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "accès refusé"}) + return + } + c.Next() + } +} + +// PrincipalFrom récupère l'identité injectée par RequireAuth. +func PrincipalFrom(c *gin.Context) *Principal { + v, ok := c.Get(ctxPrincipalKey) + if !ok { + return nil + } + p, _ := v.(Principal) + return &p +} diff --git a/control-plane/api/internal/auth/models.go b/control-plane/api/internal/auth/models.go new file mode 100644 index 0000000..6f769e6 --- /dev/null +++ b/control-plane/api/internal/auth/models.go @@ -0,0 +1,14 @@ +package auth + +import "time" + +type User struct { + ID string `gorm:"type:uuid;primaryKey" json:"id"` + Username string `gorm:"uniqueIndex;size:64;not null" json:"username"` + PasswordHash string `gorm:"not null" json:"-"` + Role Role `gorm:"size:20;not null" json:"role"` + TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"` + ExpiredAt time.Time `json:"expired_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/control-plane/api/internal/auth/password.go b/control-plane/api/internal/auth/password.go new file mode 100644 index 0000000..cbe8408 --- /dev/null +++ b/control-plane/api/internal/auth/password.go @@ -0,0 +1,61 @@ +// Package auth : hachage de mots de passe (argon2id) et JWT. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Paramètres argon2id (OWASP Password Storage Cheat Sheet). +const ( + argonTime = 1 + argonMemory = 64 * 1024 // 64 MiB + argonThreads = 4 + argonKeyLen = 32 + argonSaltLen = 16 +) + +var ErrInvalidHash = errors.New("format de hash invalide") + +// HashPassword produit un hash argon2id encodé (format PHC). +func HashPassword(password string) (string, error) { + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key), + ), nil +} + +// VerifyPassword compare un mot de passe à un hash encodé, en temps constant. +func VerifyPassword(password, encoded string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, ErrInvalidHash + } + var memory, time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false, ErrInvalidHash + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, ErrInvalidHash + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, ErrInvalidHash + } + got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1, nil +} diff --git a/control-plane/api/internal/auth/role.go b/control-plane/api/internal/auth/role.go new file mode 100644 index 0000000..a9b0431 --- /dev/null +++ b/control-plane/api/internal/auth/role.go @@ -0,0 +1,9 @@ +package auth + +// Role : rôle applicatif d'un utilisateur du control-plane. +type Role string + +const ( + RoleClient Role = "client" + RoleAdmin Role = "admin" +) diff --git a/control-plane/api/internal/auth/utils.go b/control-plane/api/internal/auth/utils.go new file mode 100644 index 0000000..e7881d4 --- /dev/null +++ b/control-plane/api/internal/auth/utils.go @@ -0,0 +1,33 @@ +package auth + +import ( + "crypto/rand" + "strings" +) + +const codeCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// GenerateCode génère un code de 16 caractères alphanumériques, +// regroupés par 4 et séparés par des tirets (ex: "A3F9-K2M7-XQ4R-8ZT1"). +func GenerateCode() (string, error) { + const length = 16 + const groupSize = 4 + + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", err + } + + var sb strings.Builder + for i := 0; i < length; i++ { + if i > 0 && i%groupSize == 0 { + sb.WriteByte('-') + } + sb.WriteByte(codeCharset[int(b[i])%len(codeCharset)]) + } + return sb.String(), nil +} + +func NormalizeUsername(u string) string { + return strings.ToLower(strings.TrimSpace(u)) +} diff --git a/control-plane/api/internal/config/config.go b/control-plane/api/internal/config/config.go new file mode 100644 index 0000000..a86858d --- /dev/null +++ b/control-plane/api/internal/config/config.go @@ -0,0 +1,81 @@ +// Package config charge la configuration depuis l'environnement. +// Secure by design : aucun secret par défaut en dur, tout vient de l'env. +package config + +import ( + "errors" + "os" + "time" +) + +type Config struct { + Addr string + JWTSecret []byte // OMNEX_JWT_SECRET (min 32 octets) — signe les tokens + SessionTTL time.Duration // durée de vie JWT + session Redis + AllowedOrigins []string // CORS (vitrine + back-office) + Env string // "dev" | "prod" + DatabaseURL string // OMNEX_DATABASE_URL (DSN PostgreSQL GORM) + RedisURL string // OMNEX_REDIS_URL (sessions), ex. redis://:pass@host:6379/0 + DemoDomain string // domaine des démos + RegistrationCode string // OMNEX_REGISTRATION_CODE (vide = inscription ouverte) + Kubeconfig string + FrontendImage string + BackendImage string +} + +// Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi. +func Load() (Config, error) { + secret := os.Getenv("OMNEX_JWT_SECRET") + if len(secret) < 32 { + return Config{}, errors.New("OMNEX_JWT_SECRET manquant ou < 32 octets") + } + + cfg := Config{ + Addr: getenv("OMNEX_ADDR", ":8080"), + JWTSecret: []byte(secret), + SessionTTL: 24 * time.Hour, + AllowedOrigins: splitCSV(getenv("OMNEX_ALLOWED_ORIGINS", "http://localhost:5173")), + Env: getenv("OMNEX_ENV", "dev"), + DatabaseURL: os.Getenv("OMNEX_DATABASE_URL"), + RedisURL: os.Getenv("OMNEX_REDIS_URL"), + DemoDomain: getenv("OMNEX_DEMO_DOMAIN", "demo.omnex.app"), + RegistrationCode: os.Getenv("OMNEX_REGISTRATION_CODE"), + Kubeconfig: os.Getenv("KUBECONFIG"), + FrontendImage: os.Getenv("FRONTEND_IMAGE_APP"), + BackendImage: os.Getenv("BACKEND_IMAGE_APP"), + } + + if cfg.Env == "prod" { + if cfg.DatabaseURL == "" { + return Config{}, errors.New("OMNEX_DATABASE_URL requis en prod") + } + if cfg.RedisURL == "" { + return Config{}, errors.New("OMNEX_REDIS_URL requis en prod (sessions)") + } + } + return cfg, nil +} + +// Secure indique si les cookies doivent porter l'attribut Secure (HTTPS). +func (c Config) Secure() bool { return c.Env == "prod" } + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func splitCSV(s string) []string { + var out []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == ',' { + if seg := s[start:i]; seg != "" { + out = append(out, seg) + } + start = i + 1 + } + } + return out +} diff --git a/control-plane/api/internal/db/db.go b/control-plane/api/internal/db/db.go new file mode 100644 index 0000000..74adaca --- /dev/null +++ b/control-plane/api/internal/db/db.go @@ -0,0 +1,55 @@ +// Package db : connexion GORM (PostgreSQL) et migrations. +package db + +import ( + "fmt" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/omnex/control-plane/api/internal/auth" + "github.com/omnex/control-plane/api/internal/demos" + "github.com/omnex/control-plane/api/internal/leads" + "github.com/omnex/control-plane/api/internal/sav" + "github.com/omnex/control-plane/api/internal/sub" +) + +// Open ouvre une connexion GORM vers PostgreSQL et configure le pool. +func Open(dsn string) (*gorm.DB, error) { + gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + SkipDefaultTransaction: true, + PrepareStmt: true, // requêtes préparées : perf + anti-injection + }) + if err != nil { + return nil, fmt.Errorf("ouverture db: %w", err) + } + + sqlDB, err := gdb.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxOpenConns(20) + sqlDB.SetMaxIdleConns(5) + sqlDB.SetConnMaxLifetime(time.Hour) + return gdb, nil +} + +// AutoMigrate crée/actualise le schéma des entités du control-plane. +func AutoMigrate(gdb *gorm.DB) error { + return gdb.AutoMigrate( + &auth.User{}, + &sub.CodeBuySub{}, + &leads.Lead{}, + &demos.Demo{}, + &demos.ExternalResource{}, + &sav.Contact{}, + ) +} + +// SeedExternalPool remplit le pool de ressources externes (idempotent). +func SeedExternalPool(gdb *gorm.DB) error { + return demos.SeedPool(gdb) +} diff --git a/control-plane/api/internal/demos/demos.go b/control-plane/api/internal/demos/demos.go new file mode 100644 index 0000000..d2e0be9 --- /dev/null +++ b/control-plane/api/internal/demos/demos.go @@ -0,0 +1,96 @@ +// Package demos : cœur métier — cycle de vie des démos client déployées +// dans le cluster (une démo = un namespace isolé, TTL 30 jours). +package demos + +import "time" + +// Paramètres capacité / durée de vie. +const ( + TTL = 30 * 24 * time.Hour // durée de vie d'une démo + MaxConcurrentDemos = 5 // capacité cible + PoolSizePerService = 5 // slots par service externe poolé +) + +// Services externes gérés par pool pré-provisionné (non auto-créables par API). +// TomTom n'y figure pas : ses clés sont créées à la volée par le worker. +const ( + ServiceTelegram = "telegram" + ServiceNowPayments = "nowpayments" +) + +// PooledServices : services nécessitant un slot de pool par démo. +var PooledServices = []string{ServiceTelegram, ServiceNowPayments} + +// Status : état d'une démo. +type Status string + +const ( + StatusPending Status = "pending" // créée, pas encore prise par le worker + StatusProvisioning Status = "provisioning" // worker en train de déployer + StatusReady Status = "ready" // démo accessible + StatusExpiring Status = "expiring" // teardown en cours + StatusExpired Status = "expired" // détruite / TTL atteint + StatusFailed Status = "failed" // échec de provisioning +) + +func (s Status) Valid() bool { + switch s { + case StatusPending, StatusProvisioning, StatusReady, StatusExpiring, StatusExpired, StatusFailed: + return true + } + return false +} + +// Active indique si la démo occupe des ressources (compte dans la capacité). +func (s Status) Active() bool { + switch s { + case StatusPending, StatusProvisioning, StatusReady, StatusExpiring: + return true + } + return false +} + +// Extendable : on ne prolonge que des démos encore vivantes. +func (s Status) Extendable() bool { + switch s { + case StatusProvisioning, StatusReady: + return true + } + return false +} + +// Demo : modèle GORM d'une démo. +type Demo struct { + ID string `gorm:"type:uuid;primaryKey" json:"id"` + Username string `gorm:"type:varchar(64);index" json:"username,omitempty"` + LeadID string `gorm:"type:varchar(36);index" json:"lead_id,omitempty"` + Status Status `gorm:"size:20;not null;index" json:"status"` + Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"` + URL string `gorm:"size:255" json:"url"` + TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (Demo) TableName() string { return "demos" } + +// ResourceStatus : état d'un slot de pool. +type ResourceStatus string + +const ( + ResourceFree ResourceStatus = "free" + ResourceBorrowed ResourceStatus = "borrowed" +) + +// ExternalResource : slot pré-provisionné (table external_pool). +// On stocke une *référence* au secret (SecretRef), jamais le secret en clair. +type ExternalResource struct { + ID uint `gorm:"primaryKey" json:"id"` + Service string `gorm:"size:20;not null;index:idx_service_status" json:"service"` + Label string `gorm:"size:120" json:"label"` // ex. "@omnex_demo_bot_1" + SecretRef string `gorm:"size:200;not null" json:"-"` // clé dans le secret manager + Status ResourceStatus `gorm:"size:20;not null;index:idx_service_status" json:"status"` + DemoID string `gorm:"type:varchar(36);index" json:"demo_id,omitempty"` // FK optionnelle (vide si libre) +} + +func (ExternalResource) TableName() string { return "external_pool" } diff --git a/control-plane/api/internal/demos/handler.go b/control-plane/api/internal/demos/handler.go new file mode 100644 index 0000000..5794d1b --- /dev/null +++ b/control-plane/api/internal/demos/handler.go @@ -0,0 +1,104 @@ +package demos + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/omnex/control-plane/api/internal/auth" +) + +type Handler struct{ svc *Service } + +func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } + +type createRequest struct { + LeadID string `json:"lead_id" binding:"omitempty,uuid4"` + Username string `json:"username" binding:"omitempty,min=3,max=64,alphanum"` +} + +// Create : POST /demos — provisionne une démo (202 Accepted, worker asynchrone). +func (h *Handler) Create(c *gin.Context) { + var req createRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) + return + } + d, err := h.svc.Create(req.LeadID, req.Username) + if err != nil { + if errors.Is(err, ErrCapacityReached) { + c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + c.JSON(http.StatusAccepted, d) +} + +// List : GET /demos. +func (h *Handler) List(c *gin.Context) { + items, err := h.svc.List() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +// ListMine : GET /demos/mine — démos rattachées au client authentifié. +func (h *Handler) ListMine(c *gin.Context) { + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) + return + } + items, err := h.svc.ListForUser(p.Username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +// Get : GET /demos/:id. +func (h *Handler) Get(c *gin.Context) { + d, err := h.svc.Get(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"}) + return + } + c.JSON(http.StatusOK, d) +} + +// Delete : DELETE /demos/:id — teardown + libération du pool. +func (h *Handler) Delete(c *gin.Context) { + d, err := h.svc.Delete(c.Param("id")) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + c.JSON(http.StatusOK, d) +} + +// Extend : POST /demos/:id/extend — prolonge de 30 jours. +func (h *Handler) Extend(c *gin.Context) { + d, err := h.svc.Extend(c.Param("id")) + if err != nil { + switch { + case errors.Is(err, ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"}) + case errors.Is(err, ErrNotExtendable): + c.JSON(http.StatusConflict, gin.H{"error": "démo non prolongeable"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + } + return + } + c.JSON(http.StatusOK, d) +} diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go new file mode 100644 index 0000000..855fba1 --- /dev/null +++ b/control-plane/api/internal/demos/helm_provisioner.go @@ -0,0 +1,337 @@ +package demos + +import ( + "bytes" + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/omnex/control-plane/api/internal/config" + "go.yaml.in/yaml/v2" + k8sCoreV1 "k8s.io/api/core/v1" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// HelmProvisioner : implémente Provisioner en appelant l'exécutable Helm. +// Nécessite que helm soit installé dans le container (ex: dans /usr/local/bin/helm). +type HelmProvisioner struct { + cfg *config.Config + k8sClient *kubernetes.Clientset + chartsDir string // chemin vers le dossier des charts (ex: /charts) + frontendImage string + backendImage string + baseDomain string + helmPath string // chemin vers l'exécutable helm (default: "helm") +} + +// NewHelmProvisioner crée un nouveau provisioner Helm. +// chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/) +// helmPath : chemin vers l'exécutable helm (optionnel, default: "helm") +func NewHelmProvisioner( + cfg *config.Config, + k8sClient *kubernetes.Clientset, + chartsDir string, + helmPath string, +) (*HelmProvisioner, error) { + if chartsDir == "" { + chartsDir = "/charts" + } + if helmPath == "" { + helmPath = "helm" + } + + // Vérifier que helm est disponible + if _, err := exec.LookPath(helmPath); err != nil { + return nil, fmt.Errorf("helm exécutable non trouvé à %s: %w", helmPath, err) + } + + return &HelmProvisioner{ + cfg: cfg, + k8sClient: k8sClient, + chartsDir: chartsDir, + frontendImage: cfg.FrontendImage, + backendImage: cfg.BackendImage, + baseDomain: cfg.DemoDomain, + helmPath: helmPath, + }, nil +} + +// Provision déploie une démo avec Helm : +// 1. Crée le namespace +// 2. Installe PostgreSQL pour la démo +// 3. Installe Redis pour la démo +// 4. Installe le chart backend +// 5. Installe le chart frontend +func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource) error { + // Créer le namespace + if err := h.createNamespace(d.Namespace); err != nil { + return fmt.Errorf("création namespace %s: %w", d.Namespace, err) + } + + // Préparer les valeurs + postgresValues := h.buildPostgresValues(d) + redisValues := h.buildRedisValues(d) + backendValues := h.buildBackendValues(d, resources) + frontendValues := h.buildFrontendValues(d) + + // Installer PostgreSQL (nécessaire pour le backend) + if err := h.installChart(d.Namespace, "postgresql", postgresValues); err != nil { + h.deleteNamespace(d.Namespace) + return fmt.Errorf("déploiement postgresql: %w", err) + } + + // Installer Redis (nécessaire pour le backend) + if err := h.installChart(d.Namespace, "redis", redisValues); err != nil { + h.deleteNamespace(d.Namespace) + return fmt.Errorf("déploiement redis: %w", err) + } + + // Installer le backend + if err := h.installChart(d.Namespace, "backend", backendValues); err != nil { + h.deleteNamespace(d.Namespace) + return fmt.Errorf("déploiement backend: %w", err) + } + + // Installer le frontend + if err := h.installChart(d.Namespace, "frontend", frontendValues); err != nil { + h.deleteNamespace(d.Namespace) + return fmt.Errorf("déploiement frontend: %w", err) + } + + // Attendre que les pods soient prêts + if err := h.waitForRollout(d.Namespace); err != nil { + log.Printf("Warning: rollout check échoué pour %s: %v", d.Namespace, err) + } + + return nil +} + +// Teardown détruit une démo en supprimant son namespace (cascading). +func (h *HelmProvisioner) Teardown(d Demo) error { + return h.deleteNamespace(d.Namespace) +} + +// createNamespace crée un namespace Kubernetes. +func (h *HelmProvisioner) createNamespace(name string) error { + ns := &k8sCoreV1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + "omnex.app/demo": "true", + }, + }, + } + _, err := h.k8sClient.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{}) + if err != nil && !k8sErrors.IsAlreadyExists(err) { + return err + } + return nil +} + +// deleteNamespace supprime un namespace (avec cascading). +func (h *HelmProvisioner) deleteNamespace(name string) error { + propagationPolicy := metav1.DeletePropagationForeground + return h.k8sClient.CoreV1().Namespaces().Delete(context.Background(), name, metav1.DeleteOptions{ + PropagationPolicy: &propagationPolicy, + }) +} + +// installChart installe un chart Helm avec des valeurs personnalisées. +func (h *HelmProvisioner) installChart(namespace, chartName string, values map[string]interface{}) error { + chartPath := filepath.Join(h.chartsDir, chartName) + + // Créer un fichier values.yaml temporaire + valuesFile := filepath.Join("/tmp", fmt.Sprintf("values-%s-%s.yaml", namespace, chartName)) + valuesYAML, err := yaml.Marshal(values) + if err != nil { + return fmt.Errorf("marshal YAML: %w", err) + } + if err := os.WriteFile(valuesFile, valuesYAML, 0644); err != nil { + return fmt.Errorf("write values file: %w", err) + } + defer os.Remove(valuesFile) + + // Construire la commande helm install + args := []string{ + "install", + fmt.Sprintf("%s-%s", namespace, chartName), + chartPath, + "--namespace", namespace, + "--values", valuesFile, + "--wait", + "--timeout", "5m", + } + + cmd := exec.Command(h.helmPath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("helm install %s: %v (stdout: %s, stderr: %s)", chartName, err, stdout.String(), stderr.String()) + } + + log.Printf("Chart %s installé dans %s", chartName, namespace) + return nil +} + +func parseImage(image string) (repo string, tag string) { + if image == "" { + return "", "helm" + } + // Chercher le dernier ":" (pour gérer les images avec port dans le repo) + idx := strings.LastIndex(image, ":") + if idx == -1 { + return image, "helm" + } + return image[:idx], image[idx+1:] +} + +// buildBackendValues construit les valeurs pour le chart backend. +func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource) map[string]interface{} { + // Parser l'image backend pour séparer repository et tag + backendRepo, backendTag := parseImage(h.backendImage) + + values := map[string]interface{}{ + "replicaCount": 1, + "image": map[string]interface{}{ + "repository": backendRepo, + "tag": backendTag, + "pullPolicy": "Always", + }, + "service": map[string]interface{}{ + "type": "ClusterIP", + "port": 8080, + }, + "autoscaling": map[string]interface{}{ + "enabled": false, + }, + "persistence": map[string]interface{}{ + "uploads": map[string]interface{}{ + "enabled": false, + }, + }, + "env": map[string]string{ + "DB_HOST": fmt.Sprintf("%s-postgresql-postgresql", d.Namespace), + "DB_PORT": "5432", + "DB_PASSWORD": "demo-postgres-pass", + "DB_USER": "postgres", + "DB_NAME": "demo_db", + "DB_SSLMODE": "disable", + "REDIS_HOST": fmt.Sprintf("%s-redis-redis", d.Namespace), + "REDIS_PORT": "6379", + "REDIS_PASSWORD": "demo-redis-pass", // Mot de passe Redis + "API_PORT": "8080", + "NODE_ENV": "production", + }, + "secrets": h.buildSecrets(resources), + } + + return values +} + +// buildFrontendValues construit les valeurs pour le chart frontend. +func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} { + backendURL := fmt.Sprintf("http://%s-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace) + // Parser l'image frontend pour séparer repository et tag + frontendRepo, frontendTag := parseImage(h.frontendImage) + + return map[string]interface{}{ + "replicaCount": 1, + "image": map[string]interface{}{ + "repository": frontendRepo, + "tag": frontendTag, + "pullPolicy": "Always", + }, + "apiUrl": backendURL, + "service": map[string]interface{}{ + "type": "ClusterIP", + "port": 80, + }, + "autoscaling": map[string]interface{}{ + "enabled": false, + }, + } +} + +// buildSecrets extrait les références des secrets des ressources externes. +func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]string { + secrets := map[string]string{} + for _, r := range resources { + switch r.Service { + case ServiceTelegram: + secrets["TELEGRAM_WEBHOOK_SECRET"] = r.SecretRef + case ServiceNowPayments: + secrets["NOWPAYMENTS_IPN_SECRET"] = r.SecretRef + } + } + return secrets +} + +// buildPostgresValues construit les valeurs pour le chart postgresql. +func (h *HelmProvisioner) buildPostgresValues(d Demo) map[string]interface{} { + return map[string]interface{}{ + "auth": map[string]interface{}{ + "password": "demo-postgres-pass", // Mot de passe OBLIGATOIRE (champ correct pour le chart) + "username": "postgres", + "database": "demo_db", + }, + "service": map[string]interface{}{ + "type": "ClusterIP", + "port": 5432, + }, + "persistence": map[string]interface{}{ + "enabled": false, + }, + } +} + +// buildRedisValues construit les valeurs pour le chart redis. +func (h *HelmProvisioner) buildRedisValues(d Demo) map[string]interface{} { + return map[string]interface{}{ + "service": map[string]interface{}{ + "type": "ClusterIP", + "port": 6379, + }, + "auth": map[string]interface{}{ + "password": "demo-redis-pass", // Mot de passe simple pour les démos + }, + "persistence": map[string]interface{}{ + "enabled": false, // Pas de persistence pour les démos + }, + } +} + +// waitForRollout attend que tous les déploiements dans le namespace soient prêts. +func (h *HelmProvisioner) waitForRollout(namespace string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(5 * time.Second): + // Utiliser kubectl pour vérifier le rollout + cmd := exec.Command("kubectl", "rollout", "status", "deployment", "-n", namespace, "--timeout=30s") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + // Si c'est juste un timeout, on continue d'attendre + if strings.Contains(stderr.String(), "timed out") { + continue + } + return fmt.Errorf("rollout check: %v: %s", err, stderr.String()) + } + // Si la commande réussit, tous les déploiements sont prêts + return nil + } + } +} diff --git a/control-plane/api/internal/demos/mem.go b/control-plane/api/internal/demos/mem.go new file mode 100644 index 0000000..45c64a1 --- /dev/null +++ b/control-plane/api/internal/demos/mem.go @@ -0,0 +1,145 @@ +package demos + +import "sync" + +// MemStore : Store en mémoire (dev/tests). +type MemStore struct { + mu sync.RWMutex + items map[string]Demo +} + +func NewMemStore() *MemStore { return &MemStore{items: make(map[string]Demo)} } + +func (m *MemStore) Create(d Demo) (Demo, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.items[d.ID] = d + return d, nil +} + +func (m *MemStore) Get(id string) (Demo, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + d, ok := m.items[id] + return d, ok +} + +func (m *MemStore) List() ([]Demo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]Demo, 0, len(m.items)) + for _, d := range m.items { + out = append(out, d) + } + return out, nil +} + +func (m *MemStore) ListByUsername(username string) ([]Demo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]Demo, 0) + for _, d := range m.items { + if d.Username == username { + out = append(out, d) + } + } + return out, nil +} + +func (m *MemStore) Update(d Demo) (Demo, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.items[d.ID] = d + return d, nil +} + +func (m *MemStore) CountActive() (int, error) { + m.mu.RLock() + defer m.mu.RUnlock() + n := 0 + for _, d := range m.items { + if d.Status.Active() { + n++ + } + } + return n, nil +} + +// MemPool : Pool en mémoire (dev/tests), PoolSizePerService slots par service. +type MemPool struct { + mu sync.Mutex + res []*ExternalResource + next uint +} + +// NewMemPool construit un pool rempli (PoolSizePerService slots libres par service). +func NewMemPool() *MemPool { + p := &MemPool{} + for _, svc := range PooledServices { + for i := 1; i <= PoolSizePerService; i++ { + p.next++ + p.res = append(p.res, &ExternalResource{ + ID: p.next, + Service: svc, + Label: placeholderLabel(svc, i), + SecretRef: "secretref://" + svc + "/slot-" + itoa(i), + Status: ResourceFree, + }) + } + } + return p +} + +func (p *MemPool) Borrow(demoID string) ([]ExternalResource, error) { + p.mu.Lock() + defer p.mu.Unlock() + // Vérifie d'abord la dispo de chaque service (atomicité). + pick := make(map[string]*ExternalResource, len(PooledServices)) + for _, svc := range PooledServices { + var found *ExternalResource + for _, r := range p.res { + if r.Service == svc && r.Status == ResourceFree { + found = r + break + } + } + if found == nil { + return nil, ErrPoolExhausted + } + pick[svc] = found + } + var out []ExternalResource + for _, r := range pick { + r.Status = ResourceBorrowed + r.DemoID = demoID + out = append(out, *r) + } + return out, nil +} + +func (p *MemPool) Return(demoID string) error { + p.mu.Lock() + defer p.mu.Unlock() + for _, r := range p.res { + if r.DemoID == demoID { + r.Status = ResourceFree + r.DemoID = "" + } + } + return nil +} + +func (p *MemPool) FreeCount() (map[string]int, error) { + p.mu.Lock() + defer p.mu.Unlock() + out := map[string]int{} + for _, svc := range PooledServices { + out[svc] = 0 + } + for _, r := range p.res { + if r.Status == ResourceFree { + out[r.Service]++ + } + } + return out, nil +} diff --git a/control-plane/api/internal/demos/pool.go b/control-plane/api/internal/demos/pool.go new file mode 100644 index 0000000..44d1f80 --- /dev/null +++ b/control-plane/api/internal/demos/pool.go @@ -0,0 +1,127 @@ +package demos + +import ( + "errors" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ErrPoolExhausted : plus de slot libre pour au moins un service requis. +var ErrPoolExhausted = errors.New("pool de ressources externes épuisé") + +// Pool : allocation des ressources externes pré-provisionnées. +type Pool interface { + // Borrow réserve un slot par service requis, de façon atomique. + // Rend ErrPoolExhausted si un service n'a plus de slot libre. + Borrow(demoID string) ([]ExternalResource, error) + // Return libère tous les slots d'une démo. + Return(demoID string) error + // FreeCount renvoie le nombre de slots libres par service. + FreeCount() (map[string]int, error) +} + +// GormPool : Pool adossé à PostgreSQL via GORM (verrou transactionnel). +type GormPool struct{ db *gorm.DB } + +func NewGormPool(db *gorm.DB) *GormPool { return &GormPool{db: db} } + +func (p *GormPool) Borrow(demoID string) ([]ExternalResource, error) { + var borrowed []ExternalResource + err := p.db.Transaction(func(tx *gorm.DB) error { + for _, svc := range PooledServices { + var r ExternalResource + // SELECT ... FOR UPDATE SKIP LOCKED : évite la course entre deux démos. + err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). + Where("service = ? AND status = ?", svc, ResourceFree). + First(&r).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPoolExhausted + } + if err != nil { + return err + } + r.Status = ResourceBorrowed + r.DemoID = demoID + if err := tx.Save(&r).Error; err != nil { + return err + } + borrowed = append(borrowed, r) + } + return nil + }) + if err != nil { + return nil, err + } + return borrowed, nil +} + +func (p *GormPool) Return(demoID string) error { + return p.db.Model(&ExternalResource{}). + Where("demo_id = ?", demoID). + Updates(map[string]any{"status": ResourceFree, "demo_id": ""}).Error +} + +func (p *GormPool) FreeCount() (map[string]int, error) { + type row struct { + Service string + N int + } + var rows []row + err := p.db.Model(&ExternalResource{}). + Select("service, count(*) as n"). + Where("status = ?", ResourceFree). + Group("service").Scan(&rows).Error + if err != nil { + return nil, err + } + out := map[string]int{} + for _, svc := range PooledServices { + out[svc] = 0 + } + for _, r := range rows { + out[r.Service] = r.N + } + return out, nil +} + +// SeedPool crée les slots manquants pour atteindre PoolSizePerService par service. +// Idempotent : n'ajoute que ce qui manque. SecretRef pointe vers le secret manager. +func SeedPool(db *gorm.DB) error { + for _, svc := range PooledServices { + var n int64 + if err := db.Model(&ExternalResource{}).Where("service = ?", svc).Count(&n).Error; err != nil { + return err + } + for i := int(n) + 1; i <= PoolSizePerService; i++ { + res := ExternalResource{ + Service: svc, + Label: placeholderLabel(svc, i), + SecretRef: "secretref://" + svc + "/slot-" + itoa(i), + Status: ResourceFree, + } + if err := db.Create(&res).Error; err != nil { + return err + } + } + } + return nil +} + +func placeholderLabel(svc string, i int) string { + return svc + "-slot-" + itoa(i) +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + var b [20]byte + pos := len(b) + for i > 0 { + pos-- + b[pos] = byte('0' + i%10) + i /= 10 + } + return string(b[pos:]) +} diff --git a/control-plane/api/internal/demos/provisioner.go b/control-plane/api/internal/demos/provisioner.go new file mode 100644 index 0000000..eb59823 --- /dev/null +++ b/control-plane/api/internal/demos/provisioner.go @@ -0,0 +1,18 @@ +package demos + +// Provisioner : abstraction du worker qui déploie/détruit la stack dans le cluster. +// L'implémentation réelle (Helm + client-go) sera branchée plus tard ; l'API se +// contente d'enfiler les intentions. +type Provisioner interface { + // Provision demande le déploiement d'une démo (asynchrone). + Provision(d Demo, resources []ExternalResource) error + // Teardown demande la destruction d'une démo. + Teardown(d Demo) error +} + +// NoopProvisioner : implémentation neutre (aucun déploiement réel). +// Sert au scaffolding et aux tests tant que le worker n'existe pas. +type NoopProvisioner struct{} + +func (NoopProvisioner) Provision(Demo, []ExternalResource) error { return nil } +func (NoopProvisioner) Teardown(Demo) error { return nil } diff --git a/control-plane/api/internal/demos/service.go b/control-plane/api/internal/demos/service.go new file mode 100644 index 0000000..bb99fcb --- /dev/null +++ b/control-plane/api/internal/demos/service.go @@ -0,0 +1,172 @@ +package demos + +import ( + "errors" + "log" + "time" + + "github.com/google/uuid" + + "github.com/omnex/control-plane/api/internal/auth" +) + +var ( + ErrCapacityReached = errors.New("capacité maximale de démos atteinte") + ErrNotFound = errors.New("démo introuvable") + ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel") +) + +// Config du service (injectable pour les tests). +type Config struct { + BaseDomain string // ex. "demo.omnex.app" + TTL time.Duration // durée de vie + MaxDemos int // capacité +} + +// Service : logique métier des démos (indépendante du transport HTTP). +type Service struct { + store Store + pool Pool + prov Provisioner + cfg Config + now func() time.Time // horloge injectable +} + +func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service { + if cfg.TTL == 0 { + cfg.TTL = TTL + } + if cfg.MaxDemos == 0 { + cfg.MaxDemos = MaxConcurrentDemos + } + if cfg.BaseDomain == "" { + cfg.BaseDomain = "demo.omnex.app" + } + return &Service{store: store, pool: pool, prov: prov, cfg: cfg, now: time.Now} +} + +// Create réserve la capacité + le pool, persiste la démo et déclenche le worker. +// username (optionnel) rattache la démo à un client existant. +func (s *Service) Create(leadID, username string) (Demo, error) { + active, err := s.store.CountActive() + if err != nil { + return Demo{}, err + } + if active >= s.cfg.MaxDemos { + return Demo{}, ErrCapacityReached + } + + id := uuid.NewString() + demo := Demo{ + ID: id, + Username: auth.NormalizeUsername(username), + LeadID: leadID, + Status: StatusPending, + Namespace: "demo-" + shortID(id), + CreatedAt: s.now().UTC(), + ExpiresAt: s.now().UTC().Add(s.cfg.TTL), + } + demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain + + // Réserve les ressources externes ; ErrPoolExhausted => capacité atteinte. + resources, err := s.pool.Borrow(id) + if err != nil { + if errors.Is(err, ErrPoolExhausted) { + return Demo{}, ErrCapacityReached + } + return Demo{}, err + } + + demo.Status = StatusProvisioning + created, err := s.store.Create(demo) + if err != nil { + _ = s.pool.Return(id) // fail-secure : on rend ce qu'on a emprunté + return Demo{}, err + } + + // Lance le déploiement de manière asynchrone (worker inline via goroutine). + go func() { + if err := s.prov.Provision(created, resources); err != nil { + log.Printf("Provisioning échoué pour %s: %v", created.ID, err) + created.Status = StatusFailed + _, _ = s.store.Update(created) + _ = s.pool.Return(id) + return + } + created.Status = StatusReady + _, _ = s.store.Update(created) + }() + + return created, nil +} + +func (s *Service) Get(id string) (Demo, error) { + d, ok := s.store.Get(id) + if !ok { + return Demo{}, ErrNotFound + } + return d, nil +} + +func (s *Service) List() ([]Demo, error) { + return s.store.List() +} + +// ListForUser retourne uniquement les démos rattachées à ce client. +func (s *Service) ListForUser(username string) ([]Demo, error) { + return s.store.ListByUsername(auth.NormalizeUsername(username)) +} + +// Delete déclenche le teardown et libère le pool. +func (s *Service) Delete(id string) (Demo, error) { + d, ok := s.store.Get(id) + if !ok { + return Demo{}, ErrNotFound + } + if d.Status == StatusExpired { + return d, nil // déjà détruite (idempotent) + } + d.Status = StatusExpiring + if _, err := s.store.Update(d); err != nil { + return Demo{}, err + } + if err := s.prov.Teardown(d); err != nil { + return Demo{}, err + } + if err := s.pool.Return(id); err != nil { + return Demo{}, err + } + d.Status = StatusExpired + return s.store.Update(d) +} + +// Extend prolonge la démo de TTL à partir de son échéance courante. +func (s *Service) Extend(id string) (Demo, error) { + d, ok := s.store.Get(id) + if !ok { + return Demo{}, ErrNotFound + } + if !d.Status.Extendable() { + return Demo{}, ErrNotExtendable + } + base := d.ExpiresAt + if now := s.now().UTC(); base.Before(now) { + base = now + } + d.ExpiresAt = base.Add(s.cfg.TTL) + return s.store.Update(d) +} + +// shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court. +func shortID(id string) string { + clean := "" + for _, c := range id { + if c != '-' { + clean += string(c) + } + if len(clean) == 8 { + break + } + } + return clean +} diff --git a/control-plane/api/internal/demos/store.go b/control-plane/api/internal/demos/store.go new file mode 100644 index 0000000..7b7af11 --- /dev/null +++ b/control-plane/api/internal/demos/store.go @@ -0,0 +1,70 @@ +package demos + +import ( + "errors" + + "gorm.io/gorm" +) + +// Store : persistance des démos. +type Store interface { + Create(d Demo) (Demo, error) + Get(id string) (Demo, bool) + List() ([]Demo, error) + ListByUsername(username string) ([]Demo, error) + Update(d Demo) (Demo, error) + CountActive() (int, error) +} + +// GormStore : Store adossé à PostgreSQL via GORM. +type GormStore struct{ db *gorm.DB } + +func NewGormStore(db *gorm.DB) *GormStore { return &GormStore{db: db} } + +func (s *GormStore) Create(d Demo) (Demo, error) { + if err := s.db.Create(&d).Error; err != nil { + return Demo{}, err + } + return d, nil +} + +func (s *GormStore) Get(id string) (Demo, bool) { + var d Demo + err := s.db.Where("id = ?", id).First(&d).Error + if errors.Is(err, gorm.ErrRecordNotFound) || err != nil { + return Demo{}, false + } + return d, true +} + +func (s *GormStore) List() ([]Demo, error) { + var out []Demo + if err := s.db.Order("created_at DESC").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +func (s *GormStore) ListByUsername(username string) ([]Demo, error) { + var out []Demo + if err := s.db.Where("username = ?", username).Order("created_at DESC").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +func (s *GormStore) Update(d Demo) (Demo, error) { + if err := s.db.Save(&d).Error; err != nil { + return Demo{}, err + } + return d, nil +} + +// CountActive compte les démos occupant de la capacité. +func (s *GormStore) CountActive() (int, error) { + var n int64 + err := s.db.Model(&Demo{}). + Where("status IN ?", []Status{StatusPending, StatusProvisioning, StatusReady, StatusExpiring}). + Count(&n).Error + return int(n), err +} diff --git a/control-plane/api/internal/httpx/security.go b/control-plane/api/internal/httpx/security.go new file mode 100644 index 0000000..7b98c5e --- /dev/null +++ b/control-plane/api/internal/httpx/security.go @@ -0,0 +1,81 @@ +// Package httpx : middlewares transverses (sécurité, CORS, rate-limit). +package httpx + +import ( + "net/http" + "sync" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +// SecurityHeaders ajoute les en-têtes de sécurité recommandés (OWASP). +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + h := c.Writer.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + c.Next() + } +} + +// CORS restreint les origines à une allowlist explicite. +func CORS(allowed []string) gin.HandlerFunc { + set := make(map[string]struct{}, len(allowed)) + for _, o := range allowed { + set[o] = struct{}{} + } + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + if _, ok := set[origin]; ok { + h := c.Writer.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Vary", "Origin") + h.Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS") + h.Set("Access-Control-Allow-Headers", "Authorization,Content-Type") + } + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} + +// ipLimiter : rate-limiter par IP (token bucket). +type ipLimiter struct { + mu sync.Mutex + buck map[string]*rate.Limiter + r rate.Limit + burst int +} + +func newIPLimiter(r rate.Limit, burst int) *ipLimiter { + return &ipLimiter{buck: make(map[string]*rate.Limiter), r: r, burst: burst} +} + +func (l *ipLimiter) get(ip string) *rate.Limiter { + l.mu.Lock() + defer l.mu.Unlock() + lim, ok := l.buck[ip] + if !ok { + lim = rate.NewLimiter(l.r, l.burst) + l.buck[ip] = lim + } + return lim +} + +// RateLimit limite chaque IP à r req/s avec un burst donné. +func RateLimit(perSecond float64, burst int) gin.HandlerFunc { + limiter := newIPLimiter(rate.Limit(perSecond), burst) + return func(c *gin.Context) { + if !limiter.get(c.ClientIP()).Allow() { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "trop de requêtes"}) + return + } + c.Next() + } +} diff --git a/control-plane/api/internal/k8s/client.go b/control-plane/api/internal/k8s/client.go new file mode 100644 index 0000000..0147df2 --- /dev/null +++ b/control-plane/api/internal/k8s/client.go @@ -0,0 +1,34 @@ +package k8s + +import ( + "fmt" + + "github.com/omnex/control-plane/api/internal/config" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) { + var ( + k8sCfg *rest.Config + err error + ) + + if cfg.Kubeconfig != "" { + k8sCfg, err = clientcmd.BuildConfigFromFlags("", cfg.Kubeconfig) + } else { + k8sCfg, err = rest.InClusterConfig() + } + if err != nil { + return nil, fmt.Errorf("k8s config: %w", err) + } + + client, err := kubernetes.NewForConfig(k8sCfg) + if err != nil { + return nil, fmt.Errorf("k8s clientset: %w", err) + } + + return client, nil +} diff --git a/control-plane/api/internal/leads/gormstore.go b/control-plane/api/internal/leads/gormstore.go new file mode 100644 index 0000000..1b94114 --- /dev/null +++ b/control-plane/api/internal/leads/gormstore.go @@ -0,0 +1,57 @@ +package leads + +import ( + "errors" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// GormStore : Store adossé à PostgreSQL via GORM. +type GormStore struct { + db *gorm.DB +} + +func NewGormStore(db *gorm.DB) *GormStore { + return &GormStore{db: db} +} + +func (s *GormStore) Create(l Lead) (Lead, error) { + l.ID = uuid.NewString() + l.Status = StatusNew + l.CreatedAt = time.Now().UTC() + if err := s.db.Create(&l).Error; err != nil { + return Lead{}, err + } + return l, nil +} + +func (s *GormStore) List() ([]Lead, error) { + var out []Lead + if err := s.db.Order("created_at DESC").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +func (s *GormStore) Get(id string) (Lead, bool) { + var l Lead + err := s.db.Where("id = ?", id).First(&l).Error + if errors.Is(err, gorm.ErrRecordNotFound) || err != nil { + return Lead{}, false + } + return l, true +} + +func (s *GormStore) SetStatus(id string, status Status) (Lead, bool) { + var l Lead + if err := s.db.Where("id = ?", id).First(&l).Error; err != nil { + return Lead{}, false + } + l.Status = status + if err := s.db.Save(&l).Error; err != nil { + return Lead{}, false + } + return l, true +} diff --git a/control-plane/api/internal/leads/handler.go b/control-plane/api/internal/leads/handler.go new file mode 100644 index 0000000..d127535 --- /dev/null +++ b/control-plane/api/internal/leads/handler.go @@ -0,0 +1,77 @@ +package leads + +import ( + "html" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + store Store +} + +func NewHandler(store Store) *Handler { + return &Handler{store: store} +} + +// createRequest : entrée publique (formulaire vitrine). Validation stricte. +type createRequest struct { + Telegram string `json:"company" binding:"required,min=2,max=120"` + Message string `json:"message" binding:"max=2000"` +} + +// Create enregistre un lead depuis le formulaire public. +// Les entrées sont nettoyées (trim + échappement HTML) avant stockage. +func (h *Handler) Create(c *gin.Context) { + var req createRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) + return + } + l, err := h.store.Create(Lead{ + Telegram: sanitize(req.Telegram), + Message: sanitize(req.Message), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + c.JSON(http.StatusCreated, l) +} + +// List renvoie tous les leads (back-office, protégé). +func (h *Handler) List(c *gin.Context) { + items, err := h.store.List() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +type statusRequest struct { + Status Status `json:"status" binding:"required"` +} + +// SetStatus met à jour le statut d'un lead (back-office, protégé). +func (h *Handler) SetStatus(c *gin.Context) { + id := c.Param("id") + var req statusRequest + if err := c.ShouldBindJSON(&req); err != nil || !req.Status.Valid() { + c.JSON(http.StatusBadRequest, gin.H{"error": "statut invalide"}) + return + } + l, ok := h.store.SetStatus(id, req.Status) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "lead introuvable"}) + return + } + c.JSON(http.StatusOK, l) +} + +// sanitize : trim + échappement HTML pour neutraliser le XSS stocké. +func sanitize(s string) string { + return html.EscapeString(strings.TrimSpace(s)) +} diff --git a/control-plane/api/internal/leads/leads.go b/control-plane/api/internal/leads/leads.go new file mode 100644 index 0000000..54d05dd --- /dev/null +++ b/control-plane/api/internal/leads/leads.go @@ -0,0 +1,96 @@ +// Package leads : gestion des prospects (feature "leads" du back-office). +package leads + +import ( + "sync" + "time" + + "github.com/google/uuid" +) + +type Status string + +const ( + StatusNew Status = "new" + StatusContacted Status = "contacted" + StatusDemo Status = "demo" + StatusWon Status = "won" + StatusLost Status = "lost" +) + +func (s Status) Valid() bool { + switch s { + case StatusNew, StatusContacted, StatusDemo, StatusWon, StatusLost: + return true + } + return false +} + +// Lead : prospect. Modèle GORM. +type Lead struct { + ID string `gorm:"type:uuid;primaryKey" json:"id"` + Telegram string `gorm:"size:120;not null" json:"telegram"` + Message string `gorm:"size:2000" json:"message"` + Status Status `gorm:"size:20;not null;index" json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// TableName force le nom de table. +func (Lead) TableName() string { return "leads" } + +// Store : persistance des leads. Impl mémoire ici, Postgres plus tard. +type Store interface { + Create(l Lead) (Lead, error) + List() ([]Lead, error) + Get(id string) (Lead, bool) + SetStatus(id string, s Status) (Lead, bool) +} + +// MemStore : implémentation en mémoire (dev/tests). +type MemStore struct { + mu sync.RWMutex + items map[string]Lead +} + +func NewMemStore() *MemStore { + return &MemStore{items: make(map[string]Lead)} +} + +func (m *MemStore) Create(l Lead) (Lead, error) { + m.mu.Lock() + defer m.mu.Unlock() + l.ID = uuid.NewString() + l.Status = StatusNew + l.CreatedAt = time.Now().UTC() + m.items[l.ID] = l + return l, nil +} + +func (m *MemStore) List() ([]Lead, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]Lead, 0, len(m.items)) + for _, l := range m.items { + out = append(out, l) + } + return out, nil +} + +func (m *MemStore) Get(id string) (Lead, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + l, ok := m.items[id] + return l, ok +} + +func (m *MemStore) SetStatus(id string, s Status) (Lead, bool) { + m.mu.Lock() + defer m.mu.Unlock() + l, ok := m.items[id] + if !ok { + return Lead{}, false + } + l.Status = s + m.items[id] = l + return l, true +} diff --git a/control-plane/api/internal/router/router.go b/control-plane/api/internal/router/router.go new file mode 100644 index 0000000..da43f86 --- /dev/null +++ b/control-plane/api/internal/router/router.go @@ -0,0 +1,86 @@ +// Package router assemble les routes de l'API Omnex. +package router + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/omnex/control-plane/api/internal/auth" + "github.com/omnex/control-plane/api/internal/config" + "github.com/omnex/control-plane/api/internal/demos" + "github.com/omnex/control-plane/api/internal/httpx" + "github.com/omnex/control-plane/api/internal/leads" + "github.com/omnex/control-plane/api/internal/sav" + "github.com/omnex/control-plane/api/internal/session" + "github.com/omnex/control-plane/api/internal/sub" +) + +// Deps : dépendances injectées (facilite les tests). +type Deps struct { + Cfg config.Config + Issuer *auth.Issuer + Sessions session.Manager + AuthH *auth.Handler + LeadsH *leads.Handler + DemosH *demos.Handler + SubH *sub.Handler + ContactH *sav.Handler +} + +// New construit l'engine Gin avec toute la chaîne de sécurité. +func New(d Deps) *gin.Engine { + if d.Cfg.Env == "prod" { + gin.SetMode(gin.ReleaseMode) + } + r := gin.New() + r.Use(gin.Recovery()) + r.Use(httpx.SecurityHeaders()) + r.Use(httpx.CORS(d.Cfg.AllowedOrigins)) + + r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + + api := r.Group("/api/v1") + + // Public : login, inscription + formulaire de contact, rate-limités. + api.POST("/auth/login", httpx.RateLimit(1, 5), d.AuthH.Login) + api.POST("/auth/register", httpx.RateLimit(0.2, 3), d.AuthH.Register) + api.POST("/leads", httpx.RateLimit(1, 3), d.LeadsH.Create) + api.POST("/send/message", httpx.RateLimit(1, 3), d.ContactH.CallSupport) + // Toute route authentifiée (session valide, n'importe quel rôle). + authed := api.Group("") + authed.Use(auth.RequireAuth(d.Issuer, d.Sessions)) + { + authed.POST("/auth/logout", d.AuthH.Logout) + authed.GET("/auth/me", d.AuthH.Me) + } + + client := authed.Group("") + client.Use(auth.RequireRole(auth.RoleClient)) + { + client.GET("/leads", d.LeadsH.List) + client.PATCH("/leads/:id/status", d.LeadsH.SetStatus) + client.POST("/subscription", d.SubH.AddCode) + if d.DemosH != nil { + client.GET("/demos/mine", d.DemosH.ListMine) + } + } + + // Espace admin : provisioning des démos (admin uniquement). + admin := authed.Group("") + admin.Use(auth.RequireRole(auth.RoleAdmin)) + { + admin.GET("/codes", d.SubH.ListCodes) + admin.POST("/codes", d.SubH.CreateCodeForBuy) + admin.GET("/messages", d.ContactH.GetMessage) + if d.DemosH != nil { + admin.POST("/demos", d.DemosH.Create) + admin.GET("/demos", d.DemosH.List) + admin.GET("/demos/:id", d.DemosH.Get) + admin.DELETE("/demos/:id", d.DemosH.Delete) + admin.POST("/demos/:id/extend", d.DemosH.Extend) + } + } + + return r +} diff --git a/control-plane/api/internal/sav/handler.go b/control-plane/api/internal/sav/handler.go new file mode 100644 index 0000000..d1832ed --- /dev/null +++ b/control-plane/api/internal/sav/handler.go @@ -0,0 +1,60 @@ +package sav + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/omnex/control-plane/api/internal/auth" +) + +type Handler struct { + store Store +} + +// NewHandler crée un nouveau Handler. +func NewHandler(store Store) *Handler { + return &Handler{store: store} +} + +type contactSupport struct { + Username string `json:"username"` + Telegram string `json:"telegram"` + Sujet string `json:"sujet"` + Message string `json:"message"` +} + +func (h *Handler) CallSupport(c *gin.Context) { + var req contactSupport + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) + return + } + + contact, err := h.store.ContactSupportByUser(req.Username, req.Telegram, req.Sujet, req.Message) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"success": "message envoyé", "contact": contact}) +} + +func (h *Handler) GetMessage(c *gin.Context) { + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) + return + } + if p.Role != "admin" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "role non correcte"}) + return + } + + getMessage, err := h.store.GetMessage() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + c.JSON(http.StatusCreated, gin.H{"messages": getMessage}) + +} diff --git a/control-plane/api/internal/sav/mem.go b/control-plane/api/internal/sav/mem.go new file mode 100644 index 0000000..5129d1d --- /dev/null +++ b/control-plane/api/internal/sav/mem.go @@ -0,0 +1,49 @@ +package sav + +import ( + "sort" + "sync" + + "github.com/google/uuid" + "github.com/omnex/control-plane/api/internal/auth" +) + +type MemContactStore struct { + mu sync.RWMutex + items map[string]Contact +} + +func NewMemContactStore() *MemContactStore { + return &MemContactStore{ + items: make(map[string]Contact), + } +} + +func (m *MemContactStore) ContactSupportByUser(username, telegram, sujet, message string) (Contact, error) { + m.mu.RLock() + defer m.mu.RUnlock() + contact := Contact{ + ID: uuid.NewString(), + Username: auth.NormalizeUsername(username), + Telegram: auth.NormalizeUsername(telegram), + Sujet: sujet, + Message: message, + } + + m.items[contact.ID] = contact + return contact, nil +} + +func (m *MemContactStore) GetMessage() ([]Contact, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + contacts := make([]Contact, 0, len(m.items)) + for _, c := range m.items { + contacts = append(contacts, c) + } + sort.Slice(contacts, func(i, j int) bool { + return contacts[i].CreatedAt.After(contacts[j].CreatedAt) + }) + return contacts, nil +} diff --git a/control-plane/api/internal/sav/models.go b/control-plane/api/internal/sav/models.go new file mode 100644 index 0000000..1de9623 --- /dev/null +++ b/control-plane/api/internal/sav/models.go @@ -0,0 +1,12 @@ +package sav + +import "time" + +type Contact struct { + ID string `gorm:"type:uuid;primaryKey" json:"id"` + Username string `gorm:"size:64;not null" json:"username"` + Telegram string `gorm:"size:64;not null" json:"telegram"` + Sujet string `gorm:"size:64;not null" json:"sujet"` + Message string `gorm:"size:64;not null" json:"message"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/control-plane/api/internal/sav/store.go b/control-plane/api/internal/sav/store.go new file mode 100644 index 0000000..4384117 --- /dev/null +++ b/control-plane/api/internal/sav/store.go @@ -0,0 +1,43 @@ +package sav + +import ( + "github.com/google/uuid" + "github.com/omnex/control-plane/api/internal/auth" + "gorm.io/gorm" +) + +type Store interface { + ContactSupportByUser(username, telegram, sujet, message string) (Contact, error) + GetMessage() ([]Contact, error) +} + +type GormStore struct { + db *gorm.DB +} + +func NewGormStore(db *gorm.DB) *GormStore { + return &GormStore{db: db} +} + +func (s *GormStore) ContactSupportByUser(username, telegram, sujet, message string) (Contact, error) { + contact := Contact{ + ID: uuid.NewString(), + Username: auth.NormalizeUsername(username), + Telegram: auth.NormalizeUsername(telegram), + Sujet: sujet, + Message: message, + } + + if err := s.db.Create(&contact).Error; err != nil { + return Contact{}, err + } + return contact, nil +} + +func (s *GormStore) GetMessage() ([]Contact, error) { + var contacts []Contact + if err := s.db.Find(&contacts).Error; err != nil { + return nil, err + } + return contacts, nil +} diff --git a/control-plane/api/internal/session/mem.go b/control-plane/api/internal/session/mem.go new file mode 100644 index 0000000..592bcd4 --- /dev/null +++ b/control-plane/api/internal/session/mem.go @@ -0,0 +1,54 @@ +package session + +import ( + "context" + "sync" + "time" +) + +// MemManager : Manager en mémoire (dev/tests), avec expiration paresseuse. +type MemManager struct { + mu sync.RWMutex + items map[string]entry + ttl time.Duration +} + +type entry struct { + s Session + expires time.Time +} + +func NewMemManager(ttl time.Duration) *MemManager { + return &MemManager{items: make(map[string]entry), ttl: ttl} +} + +func (m *MemManager) TTL() time.Duration { return m.ttl } + +func (m *MemManager) Create(_ context.Context, s Session) (string, error) { + token, err := newToken() + if err != nil { + return "", err + } + s.CreatedAt = time.Now().UTC() + m.mu.Lock() + m.items[token] = entry{s: s, expires: time.Now().Add(m.ttl)} + m.mu.Unlock() + return token, nil +} + +func (m *MemManager) Get(_ context.Context, token string) (Session, bool, error) { + m.mu.RLock() + e, ok := m.items[token] + m.mu.RUnlock() + if !ok || time.Now().After(e.expires) { + return Session{}, false, nil + } + return e.s, true, nil +} + +func (m *MemManager) Delete(_ context.Context, token string) error { + m.mu.Lock() + delete(m.items, token) + m.mu.Unlock() + return nil +} diff --git a/control-plane/api/internal/session/redis.go b/control-plane/api/internal/session/redis.go new file mode 100644 index 0000000..f5449f9 --- /dev/null +++ b/control-plane/api/internal/session/redis.go @@ -0,0 +1,65 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/redis/go-redis/v9" +) + +const keyPrefix = "omnex:session:" + +// RedisManager : Manager adossé à Redis. +type RedisManager struct { + rdb *redis.Client + ttl time.Duration +} + +func NewRedisManager(rdb *redis.Client, ttl time.Duration) *RedisManager { + return &RedisManager{rdb: rdb, ttl: ttl} +} + +func (m *RedisManager) TTL() time.Duration { return m.ttl } + +func (m *RedisManager) Create(ctx context.Context, s Session) (string, error) { + token, err := newToken() + if err != nil { + return "", err + } + s.CreatedAt = time.Now().UTC() + data, err := json.Marshal(s) + if err != nil { + return "", err + } + if err := m.rdb.Set(ctx, keyPrefix+token, data, m.ttl).Err(); err != nil { + return "", err + } + return token, nil +} + +func (m *RedisManager) Get(ctx context.Context, token string) (Session, bool, error) { + if token == "" { + return Session{}, false, nil + } + data, err := m.rdb.Get(ctx, keyPrefix+token).Bytes() + if errors.Is(err, redis.Nil) { + return Session{}, false, nil + } + if err != nil { + return Session{}, false, err + } + var s Session + if err := json.Unmarshal(data, &s); err != nil { + return Session{}, false, err + } + return s, true, nil +} + +func (m *RedisManager) Delete(ctx context.Context, token string) error { + if token == "" { + return nil + } + return m.rdb.Del(ctx, keyPrefix+token).Err() +} diff --git a/control-plane/api/internal/session/session.go b/control-plane/api/internal/session/session.go new file mode 100644 index 0000000..8c5bd01 --- /dev/null +++ b/control-plane/api/internal/session/session.go @@ -0,0 +1,41 @@ +// Package session : sessions utilisateurs stockées côté serveur dans Redis. +// Tokens opaques aléatoires (révocables), pas de JWT. +package session + +import ( + "context" + "crypto/rand" + "encoding/base64" + "time" +) + +// CookieName : nom du cookie de session (httpOnly). +const CookieName = "omnex_session" + +// Session : données associées à un token de session. +type Session struct { + UserID string `json:"user_id"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` +} + +// Manager : cycle de vie des sessions. +type Manager interface { + // Create génère un token opaque et persiste la session (TTL appliqué). + Create(ctx context.Context, s Session) (token string, err error) + // Get récupère une session par token (found=false si absente/expirée). + Get(ctx context.Context, token string) (s Session, found bool, err error) + // Delete révoque une session (logout). + Delete(ctx context.Context, token string) error + // TTL renvoie la durée de vie configurée. + TTL() time.Duration +} + +// newToken génère un token de session cryptographiquement aléatoire (256 bits). +func newToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/control-plane/api/internal/sub/handler.go b/control-plane/api/internal/sub/handler.go new file mode 100644 index 0000000..67b8cd2 --- /dev/null +++ b/control-plane/api/internal/sub/handler.go @@ -0,0 +1,126 @@ +package sub + +import ( + "crypto/rand" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/omnex/control-plane/api/internal/auth" +) + +// Handler gère les requêtes HTTP pour les codes de souscription. +type Handler struct { + store Store +} + +// NewHandler crée un nouveau Handler. +func NewHandler(store Store) *Handler { + return &Handler{store: store} +} + +// createCodeRequest représente la requête pour créer un code. +type createCodeRequest struct { + Username string `json:"username" binding:"required,min=3,max=64,alphanum"` +} + +type addCodeRequest struct { + Code string `json:"code_verif" binding:"required,len=19"` +} + +func GenerateCode() (string, error) { + const length = 16 + const groupSize = 4 + + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", err + } + + var sb strings.Builder + for i := 0; i < length; i++ { + if i > 0 && i%groupSize == 0 { + sb.WriteByte('-') + } + sb.WriteByte(codeCharset[int(b[i])%len(codeCharset)]) + } + return sb.String(), nil +} + +const codeCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// ListCodes retourne la liste de tous les codes de souscription. +// Réservé aux administrateurs. +func (h *Handler) ListCodes(c *gin.Context) { + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) + return + } + if p.Role != "admin" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "role non correcte"}) + return + } + codes, err := h.store.ListCodes() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + + c.JSON(http.StatusOK, gin.H{"items": codes}) +} + +// CreateCodeForBuy crée un nouveau code de souscription pour un utilisateur. +// Réservé aux administrateurs. +func (h *Handler) CreateCodeForBuy(c *gin.Context) { + // Vérification de l'authentification et du rôle sera faite par le middleware + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) + return + } + if p.Role != "admin" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "role non correcte"}) + return + } + var req createCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) + return + } + + code, err := GenerateCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + + codeSub, err := h.store.CreateCodeForSub(req.Username, code) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"code": code, "code_id": codeSub.ID, "username": codeSub.Username}) +} + +func (h *Handler) AddCode(c *gin.Context) { + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) + return + } + + var req addCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) + return + } + + _, err := h.store.AddCodeForSub(p.Username, req.Code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "code invalide"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": "vous passez en abonnement premium"}) +} diff --git a/control-plane/api/internal/sub/mem.go b/control-plane/api/internal/sub/mem.go new file mode 100644 index 0000000..0fb8522 --- /dev/null +++ b/control-plane/api/internal/sub/mem.go @@ -0,0 +1,56 @@ +package sub + +import ( + "sync" + + "github.com/google/uuid" + "github.com/omnex/control-plane/api/internal/auth" +) + +// MemCodeStore : implémentation en mémoire de CodeStore (dev/tests). +type MemCodeStore struct { + mu sync.RWMutex + items map[string]CodeBuySub + byCode map[string]CodeBuySub +} + +func NewMemCodeStore() *MemCodeStore { + return &MemCodeStore{ + items: make(map[string]CodeBuySub), + byCode: make(map[string]CodeBuySub), + } +} + +func (m *MemCodeStore) CreateCodeForSub(username, code string) (CodeBuySub, error) { + m.mu.Lock() + defer m.mu.Unlock() + c := CodeBuySub{ + ID: uuid.NewString(), + Username: username, + CodeBuy: code, + } + m.items[c.ID] = c + m.byCode[code] = c + return c, nil +} + +func (m *MemCodeStore) GetCodeForSub(username, code string) (auth.User, CodeBuySub, error) { + m.mu.RLock() + defer m.mu.RUnlock() + c, ok := m.byCode[code] + if !ok { + return auth.User{}, CodeBuySub{}, nil + } + // In a real implementation, we'd fetch the user too + return auth.User{}, c, nil +} + +func (m *MemCodeStore) ListCodes() ([]CodeBuySub, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]CodeBuySub, 0, len(m.items)) + for _, c := range m.items { + out = append(out, c) + } + return out, nil +} diff --git a/control-plane/api/internal/sub/models.go b/control-plane/api/internal/sub/models.go new file mode 100644 index 0000000..90bd83c --- /dev/null +++ b/control-plane/api/internal/sub/models.go @@ -0,0 +1,17 @@ +package sub + +import ( + "time" +) + +// CodeBuySub représente un code de souscription associé à un utilisateur. +type CodeBuySub struct { + ID string `gorm:"type:uuid;primaryKey" json:"id"` + Username string `gorm:"size:64;not null" json:"username"` + CodeBuy string `gorm:"uniqueIndex;type:varchar(50);not null" json:"code_verif"` + CreatedAt time.Time `gorm:"index;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +// TableName définit explicitement le nom de la table. +func (CodeBuySub) TableName() string { return "code_buy_subs" } diff --git a/control-plane/api/internal/sub/store.go b/control-plane/api/internal/sub/store.go new file mode 100644 index 0000000..39858ff --- /dev/null +++ b/control-plane/api/internal/sub/store.go @@ -0,0 +1,176 @@ +package sub + +import ( + "errors" + "sync" + "time" + + "github.com/google/uuid" + "github.com/omnex/control-plane/api/internal/auth" + "gorm.io/gorm" +) + +// Store définit les opérations de persistance pour les codes de souscription. +type Store interface { + // CreateCodeForSub crée un nouveau code pour un utilisateur. + CreateCodeForSub(username, code string) (CodeBuySub, error) + // GetCodeForSub récupère un code pour un utilisateur. + GetCodeForSub(username, code string) (CodeBuySub, error) + // ListCodes liste tous les codes. + ListCodes() ([]CodeBuySub, error) + AddCodeForSub(username, code string) (auth.User, error) +} + +// GormStore implémente Store avec PostgreSQL via GORM. +type GormStore struct { + db *gorm.DB +} + +// NewGormStore crée un nouveau GormStore. +func NewGormStore(db *gorm.DB) *GormStore { + return &GormStore{db: db} +} + +// CreateCodeForSub crée un nouveau code pour un utilisateur. +func (s *GormStore) CreateCodeForSub(username, code string) (CodeBuySub, error) { + c := CodeBuySub{ + ID: uuid.NewString(), + Username: auth.NormalizeUsername(username), + CodeBuy: code, + } + if err := s.db.Create(&c).Error; err != nil { + return CodeBuySub{}, err + } + + // S'assurer que CreatedAt est bien rempli + if c.CreatedAt.IsZero() { + c.CreatedAt = time.Now().UTC() + if err := s.db.Save(&c).Error; err != nil { + return CodeBuySub{}, err + } + } + + return c, nil +} + +// GetCodeForSub récupère un code pour un utilisateur. +func (s *GormStore) GetCodeForSub(username, code string) (CodeBuySub, error) { + var c CodeBuySub + if err := s.db.Where("username = ? AND code_buy = ?", username, code).First(&c).Error; err != nil { + return CodeBuySub{}, err + } + return c, nil +} + +func (s *GormStore) AddCodeForSub(username, code string) (auth.User, error) { + var c CodeBuySub + err := s.db.Where("username = ? AND code_buy = ?", username, code).First(&c).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return auth.User{}, errors.New("code invalide") + } + return auth.User{}, err + } + + var u auth.User + if err := s.db.Where("username = ?", auth.NormalizeUsername(username)).First(&u).Error; err != nil { + return auth.User{}, err + } + + u.TypeAbo = "premium" + u.ExpiredAt = time.Now().UTC().AddDate(0, 0, 30) + if err := s.db.Model(&u).Updates(map[string]interface{}{ + "type_abo": u.TypeAbo, + "expired_at": u.ExpiredAt, + }).Error; err != nil { + return auth.User{}, err + } + + if err := s.db.Delete(&c).Error; err != nil { + return auth.User{}, err + } + + return u, nil +} + +// ListCodes liste tous les codes, triés par date de création. +func (s *GormStore) ListCodes() ([]CodeBuySub, error) { + var codes []CodeBuySub + // Essayer avec tri par date, sinon sans tri pour compatibilité + if err := s.db.Order("created_at desc").Find(&codes).Error; err != nil { + // Si colonne manquante, on liste sans tri + if orderErr := s.db.Find(&codes).Error; orderErr != nil { + return nil, orderErr + } + } + return codes, nil +} + +// MemStore implémente Store en mémoire (pour dev/tests). +type MemStore struct { + mu sync.RWMutex + items map[string]CodeBuySub + byCode map[string]CodeBuySub +} + +// NewMemStore crée un nouveau MemStore. +func NewMemStore() *MemStore { + return &MemStore{ + items: make(map[string]CodeBuySub), + byCode: make(map[string]CodeBuySub), + } +} + +// CreateCodeForSub crée un nouveau code pour un utilisateur. +func (m *MemStore) CreateCodeForSub(username, code string) (CodeBuySub, error) { + m.mu.Lock() + defer m.mu.Unlock() + c := CodeBuySub{ + ID: uuid.NewString(), + Username: username, + CodeBuy: code, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + m.items[c.ID] = c + m.byCode[code] = c + return c, nil +} + +// GetCodeForSub récupère un code pour un utilisateur. +func (m *MemStore) GetCodeForSub(username, code string) (CodeBuySub, error) { + m.mu.RLock() + defer m.mu.RUnlock() + c, ok := m.byCode[code] + if !ok { + return CodeBuySub{}, nil + } + return c, nil +} + +// ListCodes liste tous les codes. +func (m *MemStore) ListCodes() ([]CodeBuySub, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]CodeBuySub, 0, len(m.items)) + for _, c := range m.items { + out = append(out, c) + } + return out, nil +} + +func (m *MemStore) AddCodeForSub(username, code string) (auth.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + + c, ok := m.byCode[code] + if !ok || c.Username != username { + return auth.User{}, errors.New("code invalide") + } + delete(m.byCode, code) + delete(m.items, c.ID) + + // Le MemStore ne connaît pas les utilisateurs (pas de référence à memUsers ici) : + // il ne peut donc pas mettre à jour TypeAbo lui-même. Cf. point ci-dessous. + return auth.User{Username: username, TypeAbo: "premium"}, nil +} diff --git a/control-plane/api/internal/test/auth_test.go b/control-plane/api/internal/test/auth_test.go new file mode 100644 index 0000000..5ebc713 --- /dev/null +++ b/control-plane/api/internal/test/auth_test.go @@ -0,0 +1,93 @@ +package apitest + +import ( + "context" + "testing" + "time" + + "github.com/omnex/control-plane/api/internal/auth" + "github.com/omnex/control-plane/api/internal/session" +) + +func TestHashAndVerifyPassword(t *testing.T) { + hash, err := auth.HashPassword("s3cret-password") + if err != nil { + t.Fatalf("hash: %v", err) + } + if hash == "s3cret-password" { + t.Fatal("le mot de passe ne doit pas être stocké en clair") + } + ok, err := auth.VerifyPassword("s3cret-password", hash) + if err != nil || !ok { + t.Fatalf("verify bon mdp: ok=%v err=%v", ok, err) + } + if ok, _ := auth.VerifyPassword("mauvais", hash); ok { + t.Fatal("un mauvais mot de passe ne doit pas passer") + } +} + +func TestHashUniqueSalt(t *testing.T) { + h1, _ := auth.HashPassword("same") + h2, _ := auth.HashPassword("same") + if h1 == h2 { + t.Fatal("deux hash du même mdp doivent différer (salt aléatoire)") + } +} + +func TestVerifyRejectsMalformed(t *testing.T) { + if _, err := auth.VerifyPassword("x", "pas-un-hash"); err == nil { + t.Fatal("un hash malformé doit être rejeté") + } +} + +func TestJWTIssueVerify(t *testing.T) { + iss := auth.NewIssuer([]byte(testSecret), time.Minute) + tok, err := iss.Issue("user-1", auth.RoleClient, "sid-123") + if err != nil { + t.Fatalf("issue: %v", err) + } + claims, err := iss.Verify(tok) + if err != nil { + t.Fatalf("verify: %v", err) + } + if claims.Subject != "user-1" || claims.Role != auth.RoleClient || claims.SessionID != "sid-123" { + t.Fatalf("claims inattendus: %+v", claims) + } +} + +// Sécurité : token signé avec une autre clé rejeté. +func TestJWTRejectsWrongKey(t *testing.T) { + a := auth.NewIssuer([]byte(testSecret), time.Minute) + b := auth.NewIssuer([]byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), time.Minute) + tok, _ := a.Issue("u", auth.RoleClient, "sid") + if _, err := b.Verify(tok); err == nil { + t.Fatal("un token d'une autre clé ne doit pas être accepté") + } +} + +// Sécurité : token expiré rejeté. +func TestJWTRejectsExpired(t *testing.T) { + iss := auth.NewIssuer([]byte(testSecret), -time.Minute) + tok, _ := iss.Issue("u", auth.RoleClient, "sid") + if _, err := iss.Verify(tok); err == nil { + t.Fatal("un token expiré ne doit pas être accepté") + } +} + +// Session Redis(mem) : create → get → delete. +func TestSessionManagerLifecycle(t *testing.T) { + mgr := session.NewMemManager(time.Hour) + sid, err := mgr.Create(context.Background(), session.Session{UserID: "u1", Role: "sales"}) + if err != nil || sid == "" { + t.Fatalf("create: sid=%q err=%v", sid, err) + } + if _, found, _ := mgr.Get(context.Background(), sid); !found { + t.Fatal("session attendue présente") + } + if err := mgr.Delete(context.Background(), sid); err != nil { + t.Fatalf("delete: %v", err) + } + if _, found, _ := mgr.Get(context.Background(), sid); found { + t.Fatal("session ne doit plus exister après delete") + } +} diff --git a/control-plane/api/internal/test/demos_test.go b/control-plane/api/internal/test/demos_test.go new file mode 100644 index 0000000..1c82123 --- /dev/null +++ b/control-plane/api/internal/test/demos_test.go @@ -0,0 +1,187 @@ +package apitest + +import ( + "errors" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/omnex/control-plane/api/internal/demos" +) + +func newSvc() *demos.Service { + return demos.NewService(demos.NewMemStore(), demos.NewMemPool(), demos.NoopProvisioner{}, demos.Config{}) +} + +// Feature : une démo créée est en provisioning, TTL ~30 j, URL et namespace posés. +func TestDemoCreate(t *testing.T) { + svc := newSvc() + d, err := svc.Create("") + if err != nil { + t.Fatalf("create: %v", err) + } + if d.Status != demos.StatusProvisioning { + t.Fatalf("statut attendu provisioning, reçu %s", d.Status) + } + if d.Namespace == "" || d.URL == "" { + t.Fatalf("namespace/url manquants: %+v", d) + } + ttl := d.ExpiresAt.Sub(d.CreatedAt) + if ttl < demos.TTL-time.Minute || ttl > demos.TTL+time.Minute { + t.Fatalf("TTL attendu ~30j, reçu %s", ttl) + } +} + +// Feature : borrow/return du pool cohérents. +func TestDemoPoolBorrowReturn(t *testing.T) { + pool := demos.NewMemPool() + svc := demos.NewService(demos.NewMemStore(), pool, demos.NoopProvisioner{}, demos.Config{}) + + before, _ := pool.FreeCount() + if before[demos.ServiceTelegram] != demos.PoolSizePerService { + t.Fatalf("pool initial telegram attendu %d, reçu %d", demos.PoolSizePerService, before[demos.ServiceTelegram]) + } + + d, err := svc.Create("") + if err != nil { + t.Fatalf("create: %v", err) + } + after, _ := pool.FreeCount() + if after[demos.ServiceTelegram] != demos.PoolSizePerService-1 { + t.Fatalf("après emprunt telegram attendu %d, reçu %d", demos.PoolSizePerService-1, after[demos.ServiceTelegram]) + } + + if _, err := svc.Delete(d.ID); err != nil { + t.Fatalf("delete: %v", err) + } + back, _ := pool.FreeCount() + if back[demos.ServiceTelegram] != demos.PoolSizePerService { + t.Fatalf("après restitution telegram attendu %d, reçu %d", demos.PoolSizePerService, back[demos.ServiceTelegram]) + } +} + +// Feature : capacité — la 6ᵉ démo est refusée (5 slots de pool). +func TestDemoCapacityReached(t *testing.T) { + svc := newSvc() + for i := 0; i < demos.MaxConcurrentDemos; i++ { + if _, err := svc.Create(""); err != nil { + t.Fatalf("create %d: %v", i, err) + } + } + if _, err := svc.Create(""); !errors.Is(err, demos.ErrCapacityReached) { + t.Fatalf("6ᵉ démo : attendu ErrCapacityReached, reçu %v", err) + } +} + +// Feature : la capacité se libère après destruction. +func TestDemoCapacityFreesAfterDelete(t *testing.T) { + svc := newSvc() + var first demos.Demo + for i := 0; i < demos.MaxConcurrentDemos; i++ { + d, err := svc.Create("") + if err != nil { + t.Fatalf("create %d: %v", i, err) + } + if i == 0 { + first = d + } + } + if _, err := svc.Delete(first.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := svc.Create(""); err != nil { + t.Fatalf("après libération : create doit réussir, reçu %v", err) + } +} + +// Feature : extend prolonge de 30 j. +func TestDemoExtend(t *testing.T) { + svc := newSvc() + d, _ := svc.Create("") + ext, err := svc.Extend(d.ID) + if err != nil { + t.Fatalf("extend: %v", err) + } + if !ext.ExpiresAt.After(d.ExpiresAt) { + t.Fatalf("extend doit repousser l'échéance: avant=%s après=%s", d.ExpiresAt, ext.ExpiresAt) + } +} + +// Feature : delete idempotent + not found. +func TestDemoDeleteNotFound(t *testing.T) { + svc := newSvc() + if _, err := svc.Delete("inconnu"); !errors.Is(err, demos.ErrNotFound) { + t.Fatalf("attendu ErrNotFound, reçu %v", err) + } +} + +// --- Tests HTTP (routes protégées) --- + +func TestDemosEndpointRequiresAuth(t *testing.T) { + e := newTestEnv(t) + w := e.do(http.MethodPost, "/api/v1/demos", "", gin.H{}) + if w.Code != http.StatusUnauthorized { + t.Fatalf("attendu 401 sans token, reçu %d", w.Code) + } +} + +// Sécurité : un commercial (sales/« client ») ne peut PAS provisionner de démo. +func TestDemosForbiddenForSales(t *testing.T) { + e := newTestEnv(t) + sales := e.token(t) // rôle sales + for _, tc := range []struct{ method, path string }{ + {http.MethodPost, "/api/v1/demos"}, + {http.MethodGet, "/api/v1/demos"}, + {http.MethodDelete, "/api/v1/demos/x"}, + {http.MethodPost, "/api/v1/demos/x/extend"}, + } { + w := e.do(tc.method, tc.path, sales, gin.H{}) + if w.Code != http.StatusForbidden { + t.Fatalf("%s %s : attendu 403 pour un commercial, reçu %d", tc.method, tc.path, w.Code) + } + } +} + +func TestDemosCreateAndGet(t *testing.T) { + e := newTestEnv(t) + tok := e.adminToken(t) + + w := e.do(http.MethodPost, "/api/v1/demos", tok, gin.H{}) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, reçu %d: %s", w.Code, w.Body) + } + created := decode[demos.Demo](t, w) + + g := e.do(http.MethodGet, "/api/v1/demos/"+created.ID, tok, nil) + if g.Code != http.StatusOK { + t.Fatalf("get attendu 200, reçu %d", g.Code) + } + if u := decode[demos.Demo](t, g); u.ID != created.ID { + t.Fatalf("id incohérent: %s != %s", u.ID, created.ID) + } +} + +func TestDemosGetUnknown404(t *testing.T) { + e := newTestEnv(t) + w := e.do(http.MethodGet, "/api/v1/demos/inconnu", e.adminToken(t), nil) + if w.Code != http.StatusNotFound { + t.Fatalf("attendu 404, reçu %d", w.Code) + } +} + +// Capacité via HTTP : la 6ᵉ création renvoie 409. +func TestDemosCapacityHTTP(t *testing.T) { + e := newTestEnv(t) + tok := e.adminToken(t) + for i := 0; i < demos.MaxConcurrentDemos; i++ { + if w := e.do(http.MethodPost, "/api/v1/demos", tok, gin.H{}); w.Code != http.StatusAccepted { + t.Fatalf("create %d attendu 202, reçu %d", i, w.Code) + } + } + w := e.do(http.MethodPost, "/api/v1/demos", tok, gin.H{}) + if w.Code != http.StatusConflict { + t.Fatalf("6ᵉ démo attendu 409, reçu %d", w.Code) + } +} diff --git a/control-plane/api/internal/test/helpers_test.go b/control-plane/api/internal/test/helpers_test.go new file mode 100644 index 0000000..00a4265 --- /dev/null +++ b/control-plane/api/internal/test/helpers_test.go @@ -0,0 +1,111 @@ +// Package apitest regroupe les tests unitaires et de sécurité de l'API Omnex +// (tests en boîte noire, isolés du code de production dans un dossier dédié). +package apitest + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/omnex/control-plane/api/internal/auth" + "github.com/omnex/control-plane/api/internal/config" + "github.com/omnex/control-plane/api/internal/demos" + "github.com/omnex/control-plane/api/internal/leads" + "github.com/omnex/control-plane/api/internal/router" + "github.com/omnex/control-plane/api/internal/session" +) + +const testSecret = "01234567890123456789012345678901" + +// seedUsers : UserStore mémoire pour les tests. +type seedUsers map[string]auth.User + +func (m seedUsers) ByUsername(username string) (auth.User, bool) { u, ok := m[username]; return u, ok } + +func (m seedUsers) Create(username, passwordHash string, role auth.Role) (auth.User, error) { + u := auth.User{ID: "u-" + username, Username: username, PasswordHash: passwordHash, Role: role} + m[username] = u + return u, nil +} + +// testEnv : dépendances assemblées pour un test HTTP. +type testEnv struct { + engine *gin.Engine + issuer *auth.Issuer + sessions session.Manager +} + +func newTestEnv(t *testing.T) testEnv { + return newTestEnvOpts(t) +} + +// newTestEnvOpts permet de configurer un code d'inscription. +func newTestEnvOpts(t *testing.T) testEnv { + t.Helper() + gin.SetMode(gin.TestMode) + iss := auth.NewIssuer([]byte(testSecret), time.Minute) + sessions := session.NewMemManager(time.Hour) + + hash, _ := auth.HashPassword("correct-horse") + users := seedUsers{"sales": {ID: "u1", Username: "sales", PasswordHash: hash, Role: auth.RoleClient}} + + demoSvc := demos.NewService(demos.NewMemStore(), demos.NewMemPool(), demos.NoopProvisioner{}, demos.Config{}) + + deps := router.Deps{ + Cfg: config.Config{Env: "test", AllowedOrigins: []string{"http://localhost:5173"}}, + Issuer: iss, + Sessions: sessions, + AuthH: auth.NewHandler(users, sessions, iss, false), + LeadsH: leads.NewHandler(leads.NewMemStore()), + DemosH: demos.NewHandler(demoSvc), + } + return testEnv{engine: router.New(deps), issuer: iss, sessions: sessions} +} + +// token : JWT commercial (rôle sales) adossé à une vraie session Redis(mem). +func (e testEnv) token(t *testing.T) string { return e.tokenAs(t, auth.RoleClient) } + +// adminToken : JWT administrateur. +func (e testEnv) adminToken(t *testing.T) string { return e.tokenAs(t, auth.RoleAdmin) } + +func (e testEnv) tokenAs(t *testing.T, role auth.Role) string { + t.Helper() + sid, err := e.sessions.Create(context.Background(), session.Session{UserID: "u1", Role: string(role)}) + if err != nil { + t.Fatalf("create session: %v", err) + } + tok, err := e.issuer.Issue("u1", role, sid) + if err != nil { + t.Fatalf("issue token: %v", err) + } + return tok +} + +func (e testEnv) do(method, path, token string, body any) *httptest.ResponseRecorder { + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + e.engine.ServeHTTP(w, req) + return w +} + +func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T { + t.Helper() + var v T + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatalf("decode: %v — body=%s", err, w.Body) + } + return v +} diff --git a/control-plane/api/internal/test/http_test.go b/control-plane/api/internal/test/http_test.go new file mode 100644 index 0000000..ba08061 --- /dev/null +++ b/control-plane/api/internal/test/http_test.go @@ -0,0 +1,142 @@ +package apitest + +import ( + "bytes" + "net/http" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestLoginSuccess(t *testing.T) { + e := newTestEnv(t) + w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales", "password": "correct-horse"}) + if w.Code != http.StatusOK { + t.Fatalf("login attendu 200, reçu %d: %s", w.Code, w.Body) + } + if tok := decode[map[string]any](t, w)["token"]; tok == nil || tok == "" { + t.Fatal("login doit renvoyer un token") + } + // Le cookie de session httpOnly doit être posé. + if len(w.Result().Cookies()) == 0 { + t.Fatal("login doit poser un cookie de session") + } +} + +// Inscription : crée un compte et connecte directement (201 + token). +func TestRegisterCreatesAccountAndSession(t *testing.T) { + e := newTestEnv(t) + w := e.do(http.MethodPost, "/api/v1/auth/register", "", gin.H{"username": "newsales", "password": "s3cure-pass-1"}) + if w.Code != http.StatusCreated { + t.Fatalf("register attendu 201, reçu %d: %s", w.Code, w.Body) + } + tok, _ := decode[map[string]any](t, w)["token"].(string) + if tok == "" { + t.Fatal("register doit renvoyer un token") + } + // Le token doit donner accès aux routes protégées. + if g := e.do(http.MethodGet, "/api/v1/leads", tok, nil); g.Code != http.StatusOK { + t.Fatalf("accès après inscription attendu 200, reçu %d", g.Code) + } +} + +// Inscription : username déjà pris => 409. +func TestRegisterDuplicateUsername(t *testing.T) { + e := newTestEnv(t) + // "sales" existe déjà (seedé). + w := e.do(http.MethodPost, "/api/v1/auth/register", "", gin.H{"username": "sales", "password": "s3cure-pass-1"}) + if w.Code != http.StatusConflict { + t.Fatalf("attendu 409, reçu %d", w.Code) + } +} + +// Sécurité : mot de passe trop court rejeté à la validation. +func TestRegisterRejectsWeakPassword(t *testing.T) { + e := newTestEnv(t) + w := e.do(http.MethodPost, "/api/v1/auth/register", "", gin.H{"username": "weakling", "password": "short"}) + if w.Code != http.StatusBadRequest { + t.Fatalf("attendu 400, reçu %d", w.Code) + } +} + +// Sécurité : après logout, le JWT (même valide) est refusé — session Redis révoquée. +func TestLogoutRevokesSession(t *testing.T) { + e := newTestEnv(t) + login := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales", "password": "correct-horse"}) + tok, _ := decode[map[string]any](t, login)["token"].(string) + + if w := e.do(http.MethodGet, "/api/v1/leads", tok, nil); w.Code != http.StatusOK { + t.Fatalf("accès avant logout attendu 200, reçu %d", w.Code) + } + if w := e.do(http.MethodPost, "/api/v1/auth/logout", tok, nil); w.Code != http.StatusOK { + t.Fatalf("logout attendu 200, reçu %d", w.Code) + } + // Même token, mais session supprimée => 401. + if w := e.do(http.MethodGet, "/api/v1/leads", tok, nil); w.Code != http.StatusUnauthorized { + t.Fatalf("après logout attendu 401, reçu %d", w.Code) + } +} + +func TestLoginWrongPassword(t *testing.T) { + e := newTestEnv(t) + w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales", "password": "wrong-password"}) + if w.Code != http.StatusUnauthorized { + t.Fatalf("attendu 401, reçu %d", w.Code) + } +} + +// Sécurité : pas d'énumération — même réponse pour un utilisateur inconnu. +func TestLoginUnknownUserSameResponse(t *testing.T) { + e := newTestEnv(t) + w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "ghost", "password": "whatever8"}) + if w.Code != http.StatusUnauthorized { + t.Fatalf("attendu 401 (pas d'énumération), reçu %d", w.Code) + } +} + +// Sécurité : un username avec caractères spéciaux (tentative d'injection) rejeté à la validation. +func TestLoginRejectsNonAlnumUsername(t *testing.T) { + e := newTestEnv(t) + w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales' OR '1'='1", "password": "whatever8"}) + if w.Code != http.StatusBadRequest { + t.Fatalf("attendu 400 (validation), reçu %d", w.Code) + } +} + +func TestLeadsListRequiresAuth(t *testing.T) { + e := newTestEnv(t) + w := e.do(http.MethodGet, "/api/v1/leads", "", nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("attendu 401 sans token, reçu %d", w.Code) + } +} + +func TestLeadsListRejectsForgedToken(t *testing.T) { + e := newTestEnv(t) + w := e.do(http.MethodGet, "/api/v1/leads", "eyJ.forged.token", nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("attendu 401 token forgé, reçu %d", w.Code) + } +} + +// Feature lead : création publique + XSS stocké neutralisé. +func TestCreateLeadSanitizesXSS(t *testing.T) { + e := newTestEnv(t) + payload := gin.H{"company": "", "message": "hi"} + w := e.do(http.MethodPost, "/api/v1/leads", "", payload) + if w.Code != http.StatusCreated { + t.Fatalf("attendu 201, reçu %d: %s", w.Code, w.Body) + } + list := e.do(http.MethodGet, "/api/v1/leads", e.token(t), nil) + if bytes.Contains(list.Body.Bytes(), []byte("