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