feat(developer): add API keys backend (Lot C)
- Migration 082: api_keys table (user_id, name, prefix, hashed_key, scopes, last_used_at, expires_at) - APIKey model, APIKeyService (Create, List, Delete, ValidateAPIKey) - APIKeyHandler: GET/POST/DELETE /api/v1/developer/api-keys - AuthMiddleware: X-API-Key and Bearer vza_* accepted as alternative to JWT - CSRF: skip for API key auth (stateless) - Key format: vza_ prefix, SHA-256 hashed storage
This commit is contained in:
parent
93361bf89f
commit
32348bebce
15 changed files with 439 additions and 7 deletions
|
|
@ -296,6 +296,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
|
|||
// Queue Routes
|
||||
r.setupQueueRoutes(v1)
|
||||
|
||||
// Developer Portal (API Keys)
|
||||
r.setupDeveloperRoutes(v1)
|
||||
|
||||
// Live Streams Routes
|
||||
r.setupLiveRoutes(v1)
|
||||
|
||||
|
|
|
|||
27
veza-backend-api/internal/api/routes_developer.go
Normal file
27
veza-backend-api/internal/api/routes_developer.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"veza-backend-api/internal/handlers"
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
// setupDeveloperRoutes configures developer portal routes (API keys)
|
||||
func (r *APIRouter) setupDeveloperRoutes(router *gin.RouterGroup) {
|
||||
if r.config == nil || r.config.AuthMiddleware == nil {
|
||||
return
|
||||
}
|
||||
apiKeyService := services.NewAPIKeyService(r.db.GormDB, r.logger)
|
||||
apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyService, r.logger)
|
||||
|
||||
developer := router.Group("/developer")
|
||||
developer.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
r.applyCSRFProtection(developer)
|
||||
{
|
||||
apiKeys := developer.Group("/api-keys")
|
||||
apiKeys.GET("", apiKeyHandler.ListAPIKeys)
|
||||
apiKeys.POST("", apiKeyHandler.CreateAPIKey)
|
||||
apiKeys.DELETE("/:id", apiKeyHandler.DeleteAPIKey)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ type Config struct {
|
|||
JWTService *services.JWTService
|
||||
UserService *services.UserService
|
||||
S3StorageService *services.S3StorageService // BE-SVC-005: S3 storage service
|
||||
APIKeyService *services.APIKeyService // v0.102 Lot C: developer API keys
|
||||
|
||||
// Middlewares
|
||||
RateLimiter *middleware.RateLimiter
|
||||
|
|
|
|||
|
|
@ -56,13 +56,14 @@ func (c *Config) initMiddlewares() error {
|
|||
}
|
||||
c.UserRateLimiter = middleware.NewUserRateLimiter(userRateLimiterConfig)
|
||||
|
||||
// Middleware d'authentification
|
||||
// Middleware d'authentification (supports JWT and X-API-Key for developer keys)
|
||||
c.AuthMiddleware = middleware.NewAuthMiddleware(
|
||||
c.SessionService,
|
||||
c.AuditService,
|
||||
c.PermissionService,
|
||||
c.JWTService,
|
||||
c.UserService,
|
||||
c.APIKeyService,
|
||||
c.Logger,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -74,5 +74,8 @@ func (c *Config) initServices() error {
|
|||
c.PlaylistService.SetCacheService(c.CacheService)
|
||||
}
|
||||
|
||||
// API Key Service (v0.102 Lot C - developer portal)
|
||||
c.APIKeyService = services.NewAPIKeyService(c.Database.GormDB, c.Logger)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
74
veza-backend-api/internal/handlers/api_key_handler.go
Normal file
74
veza-backend-api/internal/handlers/api_key_handler.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
apperrors "veza-backend-api/internal/errors"
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
// APIKeyHandler handles developer API key HTTP requests
|
||||
type APIKeyHandler struct {
|
||||
apiKeyService *services.APIKeyService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAPIKeyHandler creates a new APIKeyHandler
|
||||
func NewAPIKeyHandler(apiKeyService *services.APIKeyService, logger *zap.Logger) *APIKeyHandler {
|
||||
return &APIKeyHandler{apiKeyService: apiKeyService, logger: logger}
|
||||
}
|
||||
|
||||
// ListAPIKeys returns the user's API keys
|
||||
func (h *APIKeyHandler) ListAPIKeys(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
keys, err := h.apiKeyService.List(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to list API keys", err))
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"keys": keys})
|
||||
}
|
||||
|
||||
// CreateAPIKey creates a new API key
|
||||
func (h *APIKeyHandler) CreateAPIKey(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req services.CreateAPIKeyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid request body"))
|
||||
return
|
||||
}
|
||||
resp, err := h.apiKeyService.Create(c.Request.Context(), userID, &req)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to create API key", err))
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// DeleteAPIKey deletes an API key
|
||||
func (h *APIKeyHandler) DeleteAPIKey(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
keyID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid key ID"))
|
||||
return
|
||||
}
|
||||
if err := h.apiKeyService.Delete(c.Request.Context(), userID, keyID); err != nil {
|
||||
RespondWithAppError(c, apperrors.NewNotFoundError("API key not found"))
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "API key revoked"})
|
||||
}
|
||||
|
|
@ -36,23 +36,26 @@ type PermissionChecker interface {
|
|||
|
||||
// AuthMiddleware middleware d'authentification avec validation de session
|
||||
// ÉTAPE 3.4: Utilise des interfaces pour permettre l'injection de dépendances et les tests
|
||||
// v0.102: Supports X-API-Key for developer API keys (when apiKeyService is set)
|
||||
type AuthMiddleware struct {
|
||||
sessionService SessionValidator
|
||||
auditService AuditRecorder
|
||||
permissionService PermissionChecker
|
||||
jwtService *services.JWTService // T0204: Use JWTService for validation
|
||||
userService *services.UserService // T0204: Check TokenVersion
|
||||
jwtService *services.JWTService // T0204: Use JWTService for validation
|
||||
userService *services.UserService // T0204: Check TokenVersion
|
||||
apiKeyService *services.APIKeyService // v0.102: Optional, for X-API-Key auth
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAuthMiddleware crée un nouveau middleware d'authentification
|
||||
// ÉTAPE 3.4: Accepte des interfaces au lieu de types concrets pour permettre les tests avec mocks
|
||||
// apiKeyService can be nil; when set, X-API-Key header is accepted as alternative to JWT
|
||||
func NewAuthMiddleware(
|
||||
sessionService SessionValidator,
|
||||
auditService AuditRecorder,
|
||||
permissionService PermissionChecker,
|
||||
jwtService *services.JWTService,
|
||||
userService *services.UserService,
|
||||
apiKeyService *services.APIKeyService,
|
||||
logger *zap.Logger,
|
||||
) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
|
|
@ -61,6 +64,7 @@ func NewAuthMiddleware(
|
|||
permissionService: permissionService,
|
||||
jwtService: jwtService,
|
||||
userService: userService,
|
||||
apiKeyService: apiKeyService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +75,21 @@ func isSessionCheckRequest(path string) bool {
|
|||
return strings.HasSuffix(path, "/auth/me") || path == "/auth/me"
|
||||
}
|
||||
|
||||
// extractAPIKeyFromRequest extracts API key from X-API-Key or Authorization: Bearer (for developer keys)
|
||||
func extractAPIKeyFromRequest(c *gin.Context) string {
|
||||
if k := c.GetHeader("X-API-Key"); k != "" {
|
||||
return k
|
||||
}
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// authenticate performs the core authentication logic
|
||||
// Returns userID and true if successful, otherwise handles error response and returns false
|
||||
func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
|
||||
|
|
@ -90,7 +109,21 @@ func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
|
|||
}
|
||||
|
||||
if tokenString == "" {
|
||||
// Session-check endpoint (GET /auth/me) is often called without token to probe login state; log at Debug to avoid noise
|
||||
// v0.102: Try X-API-Key when no JWT (developer API keys)
|
||||
if am.apiKeyService != nil {
|
||||
if apiKey := extractAPIKeyFromRequest(c); apiKey != "" && strings.HasPrefix(apiKey, "vza_") {
|
||||
key, err := am.apiKeyService.ValidateAPIKey(c.Request.Context(), apiKey)
|
||||
if err == nil {
|
||||
c.Set("user_id", key.UserID)
|
||||
c.Set("api_key", key)
|
||||
return key.UserID, true
|
||||
}
|
||||
am.logger.Warn("Invalid API key", zap.String("ip", c.ClientIP()))
|
||||
response.Unauthorized(c, "Invalid API key")
|
||||
c.Abort()
|
||||
return uuid.Nil, false
|
||||
}
|
||||
}
|
||||
if isSessionCheckRequest(c.Request.URL.Path) {
|
||||
am.logger.Debug("Missing access token (cookie or Authorization header)",
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
|
|
@ -107,6 +140,16 @@ func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
|
|||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
// v0.102: If Bearer token looks like API key (vza_ prefix), try API key auth
|
||||
if am.apiKeyService != nil && strings.HasPrefix(tokenString, "vza_") {
|
||||
key, err := am.apiKeyService.ValidateAPIKey(c.Request.Context(), tokenString)
|
||||
if err == nil {
|
||||
c.Set("user_id", key.UserID)
|
||||
c.Set("api_key", key)
|
||||
return key.UserID, true
|
||||
}
|
||||
}
|
||||
|
||||
// T0204: Validate token using JWTService (checks sig, exp, iss, aud, alg)
|
||||
claims, err := am.jwtService.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ func setupTestAuthMiddleware(t *testing.T, jwtService *services.JWTService) (*Au
|
|||
|
||||
// ÉTAPE 3.4: Les mocks implémentent maintenant directement les interfaces
|
||||
// Plus besoin de wrappers ou de hacks - injection directe des mocks
|
||||
authMiddleware := NewAuthMiddleware(mockSessionService, mockAuditService, mockPermissionService, jwtService, userService, logger)
|
||||
authMiddleware := NewAuthMiddleware(mockSessionService, mockAuditService, mockPermissionService, jwtService, userService, nil, logger)
|
||||
|
||||
// Mock defaults for GetByID for generic tests (assume user found and version 0)
|
||||
// We use .Maybe() because not all tests will hit it (e.g. invalid token format)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,12 @@ func (m *CSRFMiddleware) Middleware() gin.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// v0.102: Skip CSRF for API key auth (X-API-Key) - no cookies, stateless
|
||||
if _, hasAPIKey := c.Get("api_key"); hasAPIKey {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer le userID depuis le contexte (défini par AuthMiddleware)
|
||||
userIDInterface, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ func setupTestAuthMiddlewareWithRBAC(t *testing.T, permissionChecker PermissionC
|
|||
jwtService := setupTestJWTService(t) // Reuse helper from auth_middleware_test.go
|
||||
userService := services.NewUserService(mockUserRepository)
|
||||
|
||||
authMiddleware := NewAuthMiddleware(mockSessionService, mockAuditService, permissionChecker, jwtService, userService, logger)
|
||||
authMiddleware := NewAuthMiddleware(mockSessionService, mockAuditService, permissionChecker, jwtService, userService, nil, logger)
|
||||
|
||||
return authMiddleware, mockSessionService, mockAuditService, mockUserRepository
|
||||
}
|
||||
|
|
|
|||
35
veza-backend-api/internal/models/api_key.go
Normal file
35
veza-backend-api/internal/models/api_key.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APIKey represents a user API key for developer portal (v0.102 Lot C)
|
||||
type APIKey struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
Prefix string `gorm:"size:16;not null;index" json:"prefix"`
|
||||
HashedKey string `gorm:"size:128;not null" json:"-"` // Never expose
|
||||
Scopes pq.StringArray `gorm:"type:text[]" json:"scopes"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName defines the table name for GORM
|
||||
func (APIKey) TableName() string {
|
||||
return "api_keys"
|
||||
}
|
||||
|
||||
// BeforeCreate GORM hook to generate UUID if not set
|
||||
func (k *APIKey) BeforeCreate(tx *gorm.DB) error {
|
||||
if k.ID == uuid.Nil {
|
||||
k.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
204
veza-backend-api/internal/services/api_key_service.go
Normal file
204
veza-backend-api/internal/services/api_key_service.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const apiKeyPrefix = "vza_"
|
||||
const apiKeyPrefixLen = 8 // Display prefix length
|
||||
|
||||
// APIKeyService manages user API keys for developer portal
|
||||
type APIKeyService struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAPIKeyService creates a new APIKeyService
|
||||
func NewAPIKeyService(db *gorm.DB, logger *zap.Logger) *APIKeyService {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &APIKeyService{db: db, logger: logger}
|
||||
}
|
||||
|
||||
// CreateAPIKeyRequest represents the request to create an API key
|
||||
type CreateAPIKeyRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
// APIKeyResponse represents an API key in API responses (no full key)
|
||||
type APIKeyResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
Scopes []string `json:"scopes"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateAPIKeyResponse includes the full key only on creation (show once)
|
||||
type CreateAPIKeyResponse struct {
|
||||
APIKeyResponse
|
||||
Key string `json:"key"` // Full key, only returned on create
|
||||
}
|
||||
|
||||
func hashKey(key string) string {
|
||||
h := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// GenerateKey generates a new API key with vza_ prefix
|
||||
func (s *APIKeyService) GenerateKey() (fullKey, prefix string, err error) {
|
||||
bytes := make([]byte, 24)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
secret := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes)
|
||||
fullKey = apiKeyPrefix + secret
|
||||
prefix = fullKey
|
||||
if len(prefix) > apiKeyPrefixLen {
|
||||
prefix = prefix[:apiKeyPrefixLen] + "..."
|
||||
}
|
||||
return fullKey, prefix, nil
|
||||
}
|
||||
|
||||
// Create creates a new API key for the user
|
||||
func (s *APIKeyService) Create(ctx context.Context, userID uuid.UUID, req *CreateAPIKeyRequest) (*CreateAPIKeyResponse, error) {
|
||||
fullKey, prefix, err := s.GenerateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scopes := req.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"read"}
|
||||
}
|
||||
scopes = normalizeScopes(scopes)
|
||||
|
||||
key := &models.APIKey{
|
||||
UserID: userID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Prefix: prefix,
|
||||
HashedKey: hashKey(fullKey),
|
||||
Scopes: scopes,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Create(key).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create API key: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("API key created",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("name", key.Name),
|
||||
zap.String("prefix", prefix))
|
||||
|
||||
return &CreateAPIKeyResponse{
|
||||
APIKeyResponse: toResponse(key),
|
||||
Key: fullKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// List returns all API keys for a user
|
||||
func (s *APIKeyService) List(ctx context.Context, userID uuid.UUID) ([]APIKeyResponse, error) {
|
||||
var keys []models.APIKey
|
||||
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Order("created_at DESC").Find(&keys).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := make([]APIKeyResponse, len(keys))
|
||||
for i := range keys {
|
||||
res[i] = toResponse(&keys[i])
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Delete removes an API key
|
||||
func (s *APIKeyService) Delete(ctx context.Context, userID, keyID uuid.UUID) error {
|
||||
result := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", keyID, userID).Delete(&models.APIKey{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAPIKey validates an API key and returns the key record (for middleware)
|
||||
func (s *APIKeyService) ValidateAPIKey(ctx context.Context, rawKey string) (*models.APIKey, error) {
|
||||
if !strings.HasPrefix(rawKey, apiKeyPrefix) {
|
||||
return nil, fmt.Errorf("invalid API key format")
|
||||
}
|
||||
hashed := hashKey(rawKey)
|
||||
|
||||
var key models.APIKey
|
||||
if err := s.db.WithContext(ctx).Where("hashed_key = ?", hashed).First(&key).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("invalid API key")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if key.ExpiresAt != nil && key.ExpiresAt.Before(time.Now()) {
|
||||
return nil, fmt.Errorf("API key expired")
|
||||
}
|
||||
|
||||
// Update last_used_at (fire-and-forget, don't block)
|
||||
now := time.Now()
|
||||
go func() {
|
||||
_ = s.db.WithContext(context.Background()).Model(&models.APIKey{}).
|
||||
Where("id = ?", key.ID).Update("last_used_at", now).Error
|
||||
}()
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// HasScope checks if the key has the required scope
|
||||
func (s *APIKeyService) HasScope(key *models.APIKey, required string) bool {
|
||||
for _, s := range key.Scopes {
|
||||
if s == required || s == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeScopes(scopes []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, s := range scopes {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s != "" && !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toResponse(k *models.APIKey) APIKeyResponse {
|
||||
return APIKeyResponse{
|
||||
ID: k.ID.String(),
|
||||
Name: k.Name,
|
||||
Prefix: k.Prefix,
|
||||
Scopes: k.Scopes,
|
||||
LastUsedAt: k.LastUsedAt,
|
||||
ExpiresAt: k.ExpiresAt,
|
||||
CreatedAt: k.CreatedAt,
|
||||
}
|
||||
}
|
||||
27
veza-backend-api/migrations/082_create_api_keys.sql
Normal file
27
veza-backend-api/migrations/082_create_api_keys.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
-- 082_create_api_keys.sql
|
||||
-- User API keys for developer portal (v0.102 Lot C)
|
||||
-- Distinct from webhook API keys (whk_); user keys use vza_ prefix
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
|
||||
name VARCHAR(100) NOT NULL,
|
||||
prefix VARCHAR(16) NOT NULL,
|
||||
hashed_key VARCHAR(128) NOT NULL,
|
||||
|
||||
scopes TEXT[] NOT NULL DEFAULT ARRAY['read'],
|
||||
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON public.api_keys(prefix);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON public.api_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON public.api_keys(expires_at) WHERE expires_at IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE public.api_keys IS 'User API keys for developer portal (X-API-Key auth)';
|
||||
COMMENT ON COLUMN public.api_keys.prefix IS 'First 8 chars of key for display (e.g. vza_abc1)';
|
||||
COMMENT ON COLUMN public.api_keys.hashed_key IS 'SHA-256 hash of full key, never stored in plaintext';
|
||||
COMMENT ON COLUMN public.api_keys.scopes IS 'Array of scopes: read, write, admin';
|
||||
7
veza-backend-api/migrations/082_create_api_keys_down.sql
Normal file
7
veza-backend-api/migrations/082_create_api_keys_down.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-- 082_create_api_keys_down.sql
|
||||
-- Rollback: drop api_keys table
|
||||
|
||||
DROP INDEX IF EXISTS idx_api_keys_expires_at;
|
||||
DROP INDEX IF EXISTS idx_api_keys_user_id;
|
||||
DROP INDEX IF EXISTS idx_api_keys_prefix;
|
||||
DROP TABLE IF EXISTS public.api_keys;
|
||||
|
|
@ -137,6 +137,7 @@ func setupAuthorizationTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *service
|
|||
permissionService,
|
||||
jwtService,
|
||||
services.NewUserServiceWithDB(repositories.NewGormUserRepository(db), db),
|
||||
nil,
|
||||
logger,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue