diff --git a/veza-backend-api/cmd/api/main.go b/veza-backend-api/cmd/api/main.go index 489998299..a142568ed 100644 --- a/veza-backend-api/cmd/api/main.go +++ b/veza-backend-api/cmd/api/main.go @@ -46,6 +46,11 @@ import ( // @in header // @name Authorization +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name X-API-Key +// @description Developer API key (obtain from Developer Portal). Format: vza_xxxxx + func main() { // Charger les variables d'environnement // NOTE: Do not write to stderr to avoid broken pipe errors with systemd journald diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index 190e0de79..b95cae1fd 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -290,6 +290,18 @@ func (r *APIRouter) Setup(router *gin.Engine) error { // Groupe API v1 (nouveau frontend React) v1 := router.Group("/api/v1") + + // v0.12.8: API key rate limiting — applied to all v1 routes + // Only affects requests authenticated via X-API-Key (JWT requests pass through) + apiKeyRateLimiter := middleware.NewAPIKeyRateLimiter(middleware.DefaultAPIKeyRateLimiterConfig()) + v1.Use(apiKeyRateLimiter.Middleware()) + + // v0.12.8: API key scope enforcement — check read/write scopes on API key requests + if r.config != nil && r.config.AuthMiddleware != nil { + apiKeySvc := services.NewAPIKeyService(r.db.GormDB, r.logger) + v1.Use(middleware.RequireAPIKeyScope(apiKeySvc)) + } + { // Auth routes first so r.authService is set for admin unlock in setupCoreProtectedRoutes if err := r.setupAuthRoutes(v1); err != nil { diff --git a/veza-backend-api/internal/middleware/api_key_rate_limiter.go b/veza-backend-api/internal/middleware/api_key_rate_limiter.go new file mode 100644 index 000000000..7d8971fd1 --- /dev/null +++ b/veza-backend-api/internal/middleware/api_key_rate_limiter.go @@ -0,0 +1,189 @@ +package middleware + +import ( + "net/http" + "strconv" + "sync" + "time" + + "veza-backend-api/internal/models" + + "github.com/gin-gonic/gin" +) + +// APIKeyRateLimiterConfig defines rate limits for API key authenticated requests. +// Limits are per API key (not per IP) to give each developer their own quota. +type APIKeyRateLimiterConfig struct { + // ReadLimit is the max number of read (GET) requests per window per key + ReadLimit int + // WriteLimit is the max number of write (POST/PUT/DELETE) requests per window per key + WriteLimit int + // Window is the sliding window duration + Window time.Duration +} + +// DefaultAPIKeyRateLimiterConfig returns default limits: 1000 reads/hour, 200 writes/hour +func DefaultAPIKeyRateLimiterConfig() *APIKeyRateLimiterConfig { + return &APIKeyRateLimiterConfig{ + ReadLimit: 1000, + WriteLimit: 200, + Window: time.Hour, + } +} + +type apiKeyEntry struct { + timestamps []time.Time +} + +// APIKeyRateLimiter rate-limits requests authenticated via API key. +// It tracks read and write operations separately, keyed by API key ID. +type APIKeyRateLimiter struct { + config *APIKeyRateLimiterConfig + readStore map[string]*apiKeyEntry + writeStore map[string]*apiKeyEntry + mu sync.Mutex + stop chan struct{} +} + +// NewAPIKeyRateLimiter creates a new API key rate limiter +func NewAPIKeyRateLimiter(config *APIKeyRateLimiterConfig) *APIKeyRateLimiter { + if config == nil { + config = DefaultAPIKeyRateLimiterConfig() + } + rl := &APIKeyRateLimiter{ + config: config, + readStore: make(map[string]*apiKeyEntry), + writeStore: make(map[string]*apiKeyEntry), + stop: make(chan struct{}), + } + go rl.cleanup() + return rl +} + +// Middleware returns a Gin middleware that enforces API key rate limits. +// It only applies to requests authenticated via API key (context has "api_key" set). +// JWT-authenticated requests pass through without additional limiting. +func (rl *APIKeyRateLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Only rate-limit API key requests + apiKeyVal, exists := c.Get("api_key") + if !exists { + c.Next() + return + } + + // Extract key ID for per-key tracking + key, ok := apiKeyVal.(*models.APIKey) + if !ok { + c.Next() + return + } + keyID := key.ID.String() + + isWrite := c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead && c.Request.Method != http.MethodOptions + + var limit int + var store map[string]*apiKeyEntry + if isWrite { + limit = rl.config.WriteLimit + store = rl.writeStore + } else { + limit = rl.config.ReadLimit + store = rl.readStore + } + + rl.mu.Lock() + now := time.Now() + cutoff := now.Add(-rl.config.Window) + + entry, ok := store[keyID] + if !ok { + entry = &apiKeyEntry{} + store[keyID] = entry + } + + // Prune expired timestamps + valid := make([]time.Time, 0, len(entry.timestamps)) + for _, t := range entry.timestamps { + if t.After(cutoff) { + valid = append(valid, t) + } + } + + if len(valid) >= limit { + entry.timestamps = valid + rl.mu.Unlock() + + resetTime := now.Add(rl.config.Window).Unix() + retryAfter := int(rl.config.Window.Seconds()) + + c.Header("X-RateLimit-Limit", strconv.Itoa(limit)) + c.Header("X-RateLimit-Remaining", "0") + c.Header("X-RateLimit-Reset", strconv.FormatInt(resetTime, 10)) + c.Header("Retry-After", strconv.Itoa(retryAfter)) + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "error": gin.H{ + "code": "RATE_LIMIT_EXCEEDED", + "message": "API key rate limit exceeded. Please try again later.", + "retry_after": retryAfter, + "limit": limit, + "remaining": 0, + "reset": resetTime, + }, + }) + c.Abort() + return + } + + valid = append(valid, now) + entry.timestamps = valid + remaining := limit - len(valid) + rl.mu.Unlock() + + c.Header("X-RateLimit-Limit", strconv.Itoa(limit)) + c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining)) + c.Header("X-RateLimit-Reset", strconv.FormatInt(now.Add(rl.config.Window).Unix(), 10)) + + c.Next() + } +} + +// Stop signals the cleanup goroutine to exit +func (rl *APIKeyRateLimiter) Stop() { + close(rl.stop) +} + +func (rl *APIKeyRateLimiter) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + rl.mu.Lock() + cutoff := time.Now().Add(-rl.config.Window) + pruneStore(rl.readStore, cutoff) + pruneStore(rl.writeStore, cutoff) + rl.mu.Unlock() + case <-rl.stop: + return + } + } +} + +func pruneStore(store map[string]*apiKeyEntry, cutoff time.Time) { + for key, entry := range store { + valid := make([]time.Time, 0, len(entry.timestamps)) + for _, t := range entry.timestamps { + if t.After(cutoff) { + valid = append(valid, t) + } + } + if len(valid) == 0 { + delete(store, key) + } else { + entry.timestamps = valid + } + } +} diff --git a/veza-backend-api/internal/middleware/api_key_rate_limiter_test.go b/veza-backend-api/internal/middleware/api_key_rate_limiter_test.go new file mode 100644 index 000000000..a7897b3ba --- /dev/null +++ b/veza-backend-api/internal/middleware/api_key_rate_limiter_test.go @@ -0,0 +1,216 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "veza-backend-api/internal/models" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestAPIKeyRateLimiter_PassthroughWithoutAPIKey(t *testing.T) { + rl := NewAPIKeyRateLimiter(DefaultAPIKeyRateLimiterConfig()) + defer rl.Stop() + + router := gin.New() + router.Use(rl.Middleware()) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + // Request without api_key in context — should pass through + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestAPIKeyRateLimiter_EnforcesReadLimit(t *testing.T) { + config := &APIKeyRateLimiterConfig{ + ReadLimit: 3, + WriteLimit: 2, + Window: time.Minute, + } + rl := NewAPIKeyRateLimiter(config) + defer rl.Stop() + + keyID := uuid.New() + apiKey := &models.APIKey{ID: keyID, UserID: uuid.New(), Name: "test"} + + router := gin.New() + // Simulate auth middleware setting api_key + router.Use(func(c *gin.Context) { + c.Set("api_key", apiKey) + c.Next() + }) + router.Use(rl.Middleware()) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + // First 3 requests should pass + for i := 0; i < 3; i++ { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("request %d: expected 200, got %d", i+1, w.Code) + } + } + + // 4th request should be rate limited + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + if w.Code != http.StatusTooManyRequests { + t.Errorf("4th request: expected 429, got %d", w.Code) + } + + // Check rate limit headers + if w.Header().Get("X-RateLimit-Limit") != "3" { + t.Errorf("expected X-RateLimit-Limit=3, got %s", w.Header().Get("X-RateLimit-Limit")) + } + if w.Header().Get("X-RateLimit-Remaining") != "0" { + t.Errorf("expected X-RateLimit-Remaining=0, got %s", w.Header().Get("X-RateLimit-Remaining")) + } +} + +func TestAPIKeyRateLimiter_EnforcesWriteLimit(t *testing.T) { + config := &APIKeyRateLimiterConfig{ + ReadLimit: 100, + WriteLimit: 2, + Window: time.Minute, + } + rl := NewAPIKeyRateLimiter(config) + defer rl.Stop() + + keyID := uuid.New() + apiKey := &models.APIKey{ID: keyID, UserID: uuid.New(), Name: "test"} + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("api_key", apiKey) + c.Next() + }) + router.Use(rl.Middleware()) + router.POST("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + // First 2 POST requests should pass + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/test", nil) + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("request %d: expected 200, got %d", i+1, w.Code) + } + } + + // 3rd POST should be rate limited + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/test", nil) + router.ServeHTTP(w, req) + if w.Code != http.StatusTooManyRequests { + t.Errorf("3rd POST: expected 429, got %d", w.Code) + } +} + +func TestAPIKeyRateLimiter_SeparateKeysHaveSeparateLimits(t *testing.T) { + config := &APIKeyRateLimiterConfig{ + ReadLimit: 2, + WriteLimit: 2, + Window: time.Minute, + } + rl := NewAPIKeyRateLimiter(config) + defer rl.Stop() + + key1 := &models.APIKey{ID: uuid.New(), UserID: uuid.New(), Name: "key1"} + key2 := &models.APIKey{ID: uuid.New(), UserID: uuid.New(), Name: "key2"} + + makeRouter := func(apiKey *models.APIKey) *gin.Engine { + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("api_key", apiKey) + c.Next() + }) + r.Use(rl.Middleware()) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + return r + } + + router1 := makeRouter(key1) + router2 := makeRouter(key2) + + // Exhaust key1's limit + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router1.ServeHTTP(w, req) + } + + // key1 should be rate limited + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router1.ServeHTTP(w, req) + if w.Code != http.StatusTooManyRequests { + t.Errorf("key1 3rd request: expected 429, got %d", w.Code) + } + + // key2 should still work + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/test", nil) + router2.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("key2 1st request: expected 200, got %d", w.Code) + } +} + +func TestAPIKeyRateLimiter_ReturnsRateLimitHeaders(t *testing.T) { + config := &APIKeyRateLimiterConfig{ + ReadLimit: 10, + WriteLimit: 5, + Window: time.Hour, + } + rl := NewAPIKeyRateLimiter(config) + defer rl.Stop() + + apiKey := &models.APIKey{ID: uuid.New(), UserID: uuid.New(), Name: "test"} + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("api_key", apiKey) + c.Next() + }) + router.Use(rl.Middleware()) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + + if w.Header().Get("X-RateLimit-Limit") != "10" { + t.Errorf("expected X-RateLimit-Limit=10, got %s", w.Header().Get("X-RateLimit-Limit")) + } + if w.Header().Get("X-RateLimit-Remaining") != "9" { + t.Errorf("expected X-RateLimit-Remaining=9, got %s", w.Header().Get("X-RateLimit-Remaining")) + } + if w.Header().Get("X-RateLimit-Reset") == "" { + t.Error("expected X-RateLimit-Reset header to be set") + } +} diff --git a/veza-backend-api/internal/middleware/api_key_scope.go b/veza-backend-api/internal/middleware/api_key_scope.go new file mode 100644 index 000000000..4901f75de --- /dev/null +++ b/veza-backend-api/internal/middleware/api_key_scope.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "net/http" + + "veza-backend-api/internal/models" + "veza-backend-api/internal/services" + + "github.com/gin-gonic/gin" +) + +// RequireAPIKeyScope returns a Gin middleware that enforces API key scopes. +// For API key authenticated requests, it verifies the key has the required scope. +// JWT-authenticated requests pass through (they already use RBAC). +// +// Scope mapping: +// - GET/HEAD/OPTIONS → "read" +// - POST/PUT/PATCH/DELETE → "write" +// - "admin" scope implies both "read" and "write" +func RequireAPIKeyScope(apiKeyService *services.APIKeyService) gin.HandlerFunc { + return func(c *gin.Context) { + apiKeyVal, exists := c.Get("api_key") + if !exists { + // Not an API key request (JWT auth) — pass through + c.Next() + return + } + + key, ok := apiKeyVal.(*models.APIKey) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": gin.H{ + "code": "INTERNAL_ERROR", + "message": "Invalid API key context", + }, + }) + c.Abort() + return + } + + var requiredScope string + switch c.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + requiredScope = "read" + default: + requiredScope = "write" + } + + if !apiKeyService.HasScope(key, requiredScope) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "error": gin.H{ + "code": "INSUFFICIENT_SCOPE", + "message": "API key does not have the required scope: " + requiredScope, + "required_scope": requiredScope, + "key_scopes": key.Scopes, + }, + }) + c.Abort() + return + } + + c.Next() + } +} diff --git a/veza-backend-api/internal/middleware/api_key_scope_test.go b/veza-backend-api/internal/middleware/api_key_scope_test.go new file mode 100644 index 000000000..b1ddcde91 --- /dev/null +++ b/veza-backend-api/internal/middleware/api_key_scope_test.go @@ -0,0 +1,149 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "veza-backend-api/internal/models" + "veza-backend-api/internal/services" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/lib/pq" + "go.uber.org/zap" +) + +func TestRequireAPIKeyScope_PassthroughWithoutAPIKey(t *testing.T) { + svc := services.NewAPIKeyService(nil, zap.NewNop()) + + router := gin.New() + router.Use(RequireAPIKeyScope(svc)) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestRequireAPIKeyScope_AllowsReadScopeOnGET(t *testing.T) { + svc := services.NewAPIKeyService(nil, zap.NewNop()) + + apiKey := &models.APIKey{ + ID: uuid.New(), + UserID: uuid.New(), + Name: "test", + Scopes: pq.StringArray{"read"}, + } + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("api_key", apiKey) + c.Next() + }) + router.Use(RequireAPIKeyScope(svc)) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestRequireAPIKeyScope_DeniesWriteWithReadOnlyScope(t *testing.T) { + svc := services.NewAPIKeyService(nil, zap.NewNop()) + + apiKey := &models.APIKey{ + ID: uuid.New(), + UserID: uuid.New(), + Name: "test", + Scopes: pq.StringArray{"read"}, + } + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("api_key", apiKey) + c.Next() + }) + router.Use(RequireAPIKeyScope(svc)) + router.POST("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/test", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestRequireAPIKeyScope_AllowsWriteWithWriteScope(t *testing.T) { + svc := services.NewAPIKeyService(nil, zap.NewNop()) + + apiKey := &models.APIKey{ + ID: uuid.New(), + UserID: uuid.New(), + Name: "test", + Scopes: pq.StringArray{"read", "write"}, + } + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("api_key", apiKey) + c.Next() + }) + router.Use(RequireAPIKeyScope(svc)) + router.POST("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/test", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestRequireAPIKeyScope_AdminScopeAllowsAll(t *testing.T) { + svc := services.NewAPIKeyService(nil, zap.NewNop()) + + apiKey := &models.APIKey{ + ID: uuid.New(), + UserID: uuid.New(), + Name: "test", + Scopes: pq.StringArray{"admin"}, + } + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("api_key", apiKey) + c.Next() + }) + router.Use(RequireAPIKeyScope(svc)) + router.DELETE("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", "/test", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d (admin should have write access)", w.Code) + } +} diff --git a/veza-backend-api/openapi.yaml b/veza-backend-api/openapi.yaml index 1bb999e8d..d402fef82 100644 --- a/veza-backend-api/openapi.yaml +++ b/veza-backend-api/openapi.yaml @@ -3429,7 +3429,13 @@ paths: - Webhook securityDefinitions: BearerAuth: + description: "JWT Bearer token. Format: Bearer {token}" in: header name: Authorization type: apiKey + ApiKeyAuth: + description: "Developer API key. Format: vza_xxxxx (obtain from Developer Portal)" + in: header + name: X-API-Key + type: apiKey swagger: "2.0"