feat(v0.12.8): documentation & API publique — rate limiting, scopes, OpenAPI

- API key rate limiting middleware (1000 reads/h, 200 writes/h par clé)
  — tracking séparé read/write, par API key ID (pas par IP)
  — headers X-RateLimit-Limit/Remaining/Reset sur chaque réponse
- API key scope enforcement middleware (read → GET, write → POST/PUT/DELETE)
  — admin scope permet tout, CSRF skip pour API key auth
- OpenAPI spec: ajout securityDefinition ApiKeyAuth (X-API-Key header)
- Swagger annotations: ajout ApiKeyAuth dans cmd/api/main.go
- Wiring dans router.go: middlewares appliqués sur tout le groupe /api/v1
- Tests: 10 tests (5 rate limiter + 5 scope enforcement), tous PASS

Backend existant déjà en place (pré-v0.12.8):
- Swagger UI (gin-swagger + frontend SwaggerUIDoc component)
- API key CRUD (create/list/delete + X-API-Key auth dans AuthMiddleware)
- Developer Dashboard frontend (API keys, webhooks, playground)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
senke 2026-03-12 18:44:09 +01:00
parent 240d1370e9
commit b47fa21331
7 changed files with 643 additions and 0 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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
}
}
}

View file

@ -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")
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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"