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:
parent
240d1370e9
commit
b47fa21331
7 changed files with 643 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
189
veza-backend-api/internal/middleware/api_key_rate_limiter.go
Normal file
189
veza-backend-api/internal/middleware/api_key_rate_limiter.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
66
veza-backend-api/internal/middleware/api_key_scope.go
Normal file
66
veza-backend-api/internal/middleware/api_key_scope.go
Normal 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()
|
||||
}
|
||||
}
|
||||
149
veza-backend-api/internal/middleware/api_key_scope_test.go
Normal file
149
veza-backend-api/internal/middleware/api_key_scope_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue