feat: add api
docker-publish / publish (push) Failing after 14m14s

This commit is contained in:
Nuxgrid
2026-07-27 16:06:19 +02:00
parent 8397a4470f
commit 6b4274d12b
46 changed files with 3922 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.env
.env.*
*_test.go
test/
Dockerfile
.dockerignore
+30
View File
@@ -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
+20
View File
@@ -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"]
+167
View File
@@ -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
}
+80
View File
@@ -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
)
+195
View File
@@ -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=
@@ -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
}
+176
View File
@@ -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"
+63
View File
@@ -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
}
@@ -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
}
+14
View File
@@ -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"`
}
@@ -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
}
+9
View File
@@ -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"
)
+33
View File
@@ -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))
}
@@ -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
}
+55
View File
@@ -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)
}
+96
View File
@@ -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" }
+104
View File
@@ -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)
}
@@ -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
}
}
}
+145
View File
@@ -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
}
+127
View File
@@ -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:])
}
@@ -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 }
+172
View File
@@ -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
}
+70
View File
@@ -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
}
@@ -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()
}
}
+34
View File
@@ -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
}
@@ -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
}
@@ -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))
}
+96
View File
@@ -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
}
@@ -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
}
+60
View File
@@ -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})
}
+49
View File
@@ -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
}
+12
View File
@@ -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"`
}
+43
View File
@@ -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
}
+54
View File
@@ -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
}
@@ -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()
}
@@ -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
}
+126
View File
@@ -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"})
}
+56
View File
@@ -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
}
+17
View File
@@ -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" }
+176
View File
@@ -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
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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
}
@@ -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": "<script>alert(1)</script>", "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("<script>")) {
t.Fatal("le HTML brut ne doit pas être stocké/renvoyé (XSS)")
}
}
func TestCreateLeadRejectsInvalidEmail(t *testing.T) {
e := newTestEnv(t)
w := e.do(http.MethodPost, "/api/v1/leads", "", gin.H{"company": "Corp", "email": "not-an-email"})
if w.Code != http.StatusBadRequest {
t.Fatalf("attendu 400, reçu %d", w.Code)
}
}
@@ -0,0 +1,63 @@
package apitest
import (
"os"
"testing"
"gorm.io/gorm"
"github.com/omnex/control-plane/api/internal/db"
"github.com/omnex/control-plane/api/internal/demos"
"github.com/omnex/control-plane/api/internal/leads"
)
// Tests d'intégration GORM/PostgreSQL. Skippés si OMNEX_TEST_DATABASE_URL absent.
// En CI : lancés contre un PostgreSQL éphémère (testcontainers-go / service kind).
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("OMNEX_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("OMNEX_TEST_DATABASE_URL non défini — test d'intégration ignoré")
}
gdb, err := db.Open(dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(gdb); err != nil {
t.Fatalf("migrate: %v", err)
}
return gdb
}
func TestLeadsGormCRUD(t *testing.T) {
gdb := openTestDB(t)
store := leads.NewGormStore(gdb)
created, err := store.Create(leads.Lead{Telegram: "Corp", Message: "hi"})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.ID == "" || created.Status != leads.StatusNew {
t.Fatalf("lead créé invalide: %+v", created)
}
if _, ok := store.SetStatus(created.ID, leads.StatusContacted); !ok {
t.Fatal("setstatus a échoué")
}
}
func TestDemosGormPoolAndCapacity(t *testing.T) {
gdb := openTestDB(t)
if err := db.SeedExternalPool(gdb); err != nil {
t.Fatalf("seed pool: %v", err)
}
svc := demos.NewService(demos.NewGormStore(gdb), demos.NewGormPool(gdb), demos.NoopProvisioner{}, demos.Config{})
d, err := svc.Create("")
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := svc.Delete(d.ID); err != nil {
t.Fatalf("delete: %v", err)
}
}