gohorsejobs/backend/internal/services/credentials_service.go
Tiago Yamamoto fa1d397c01 fix: sync credentials services between backend and frontend
- Update ListConfiguredServices to use correct service names
- Add fcm_service_account, appwrite, smtp to BootstrapCredentials
- Remove unused payment_gateway from frontend schema
- Rename firebase to fcm_service_account in frontend
2026-02-23 16:20:25 -06:00

377 lines
12 KiB
Go

package services
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"sync"
)
type CredentialsService struct {
DB *sql.DB
// Cache for decrypted keys
cache map[string]string
cacheMutex sync.RWMutex
}
func NewCredentialsService(db *sql.DB) *CredentialsService {
return &CredentialsService{
DB: db,
cache: make(map[string]string),
}
}
// SaveCredentials saves the encrypted payload for a service
func (s *CredentialsService) SaveCredentials(ctx context.Context, serviceName, encryptedPayload, updatedBy string) error {
query := `
INSERT INTO external_services_credentials (service_name, encrypted_payload, updated_by, updated_at)
VALUES ($1, $2, NULLIF($3, '')::uuid, NOW())
ON CONFLICT (service_name)
DO UPDATE SET
encrypted_payload = EXCLUDED.encrypted_payload,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
`
_, err := s.DB.ExecContext(ctx, query, serviceName, encryptedPayload, updatedBy)
if err != nil {
return err
}
// Invalidate cache
s.cacheMutex.Lock()
delete(s.cache, serviceName)
s.cacheMutex.Unlock()
return nil
}
// GetDecryptedKey retrieves and decrypts the key for a service
func (s *CredentialsService) GetDecryptedKey(ctx context.Context, serviceName string) (string, error) {
// Check cache first
// Cache DISABLED to support external updates from Backoffice
/*
s.cacheMutex.RLock()
if val, ok := s.cache[serviceName]; ok {
s.cacheMutex.RUnlock()
return val, nil
}
s.cacheMutex.RUnlock()
*/
// Fetch from DB
var encryptedPayload string
query := `SELECT encrypted_payload FROM external_services_credentials WHERE service_name = $1`
err := s.DB.QueryRowContext(ctx, query, serviceName).Scan(&encryptedPayload)
if err == sql.ErrNoRows {
return "", fmt.Errorf("credentials for service %s not found", serviceName)
}
if err != nil {
return "", err
}
// Decrypt
decrypted, err := s.decryptPayload(encryptedPayload)
if err != nil {
return "", fmt.Errorf("failed to decrypt credentials: %w", err)
}
// Update cache
s.cacheMutex.Lock()
s.cache[serviceName] = decrypted
s.cacheMutex.Unlock()
return decrypted, nil
}
func (s *CredentialsService) decryptPayload(encryptedPayload string) (string, error) {
// 1. Decode Private Key from Env
rawPrivateKey, err := base64.StdEncoding.DecodeString(getEnvRSAKey())
if err != nil {
return "", fmt.Errorf("failed to decode env RSA private key: %w", err)
}
block, _ := pem.Decode(rawPrivateKey)
if block == nil {
return "", fmt.Errorf("failed to parse PEM block containing the private key")
}
privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
// Try generic PKCS8 if PKCS1 fails
if key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes); err2 == nil {
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
privKey = rsaKey
} else {
return "", fmt.Errorf("key is not RSA")
}
} else {
return "", err
}
}
// 2. Decode ciphertext
ciphertext, err := base64.StdEncoding.DecodeString(encryptedPayload)
if err != nil {
return "", err
}
// 3. Decrypt using RSA-OAEP
plaintext, err := rsa.DecryptOAEP(
sha256.New(),
rand.Reader,
privKey,
ciphertext,
nil,
)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// ConfiguredService represents a service with saved credentials (without revealing the actual value)
type ConfiguredService struct {
ServiceName string `json:"service_name"`
UpdatedAt string `json:"updated_at"`
UpdatedBy string `json:"updated_by,omitempty"`
IsConfigured bool `json:"is_configured"`
}
// ListConfiguredServices returns all configured services without revealing credential values
func (s *CredentialsService) ListConfiguredServices(ctx context.Context) ([]ConfiguredService, error) {
// Define all supported services - must match BootstrapCredentials keys
allServices := []string{
"stripe",
"storage",
"cloudflare_config",
"cpanel",
"lavinmq",
"appwrite",
"fcm_service_account",
"smtp",
}
query := `
SELECT service_name, updated_at, COALESCE(updated_by::text, '') as updated_by
FROM external_services_credentials
`
rows, err := s.DB.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
// Map of configured services
configured := make(map[string]ConfiguredService)
for rows.Next() {
var cs ConfiguredService
if err := rows.Scan(&cs.ServiceName, &cs.UpdatedAt, &cs.UpdatedBy); err != nil {
return nil, err
}
cs.IsConfigured = true
configured[cs.ServiceName] = cs
}
// Build result with all services
result := make([]ConfiguredService, 0, len(allServices))
for _, name := range allServices {
if cs, ok := configured[name]; ok {
result = append(result, cs)
} else {
result = append(result, ConfiguredService{
ServiceName: name,
IsConfigured: false,
})
}
}
return result, nil
}
// DeleteCredentials removes credentials for a service
func (s *CredentialsService) DeleteCredentials(ctx context.Context, serviceName string) error {
query := `DELETE FROM external_services_credentials WHERE service_name = $1`
_, err := s.DB.ExecContext(ctx, query, serviceName)
if err != nil {
return err
}
// Clear cache
s.cacheMutex.Lock()
delete(s.cache, serviceName)
s.cacheMutex.Unlock()
return nil
}
// EncryptPayload encrypts a payload using the derived public key
func (s *CredentialsService) EncryptPayload(payload string) (string, error) {
// 1. Decode Private Key from Env (to derive Public Key)
// In a real scenario, you might store Public Key separately, but we can derive it.
rawPrivateKey, err := base64.StdEncoding.DecodeString(getEnvRSAKey())
if err != nil {
return "", fmt.Errorf("failed to decode env RSA private key: %w", err)
}
block, _ := pem.Decode(rawPrivateKey)
if block == nil {
return "", fmt.Errorf("failed to parse PEM block containing the private key")
}
privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
// Try generic PKCS8 if PKCS1 fails
if key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes); err2 == nil {
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
privKey = rsaKey
} else {
return "", fmt.Errorf("key is not RSA")
}
} else {
return "", err
}
}
pubKey := &privKey.PublicKey
// 2. Encrypt using RSA-OAEP
ciphertext, err := rsa.EncryptOAEP(
sha256.New(),
rand.Reader,
pubKey,
[]byte(payload),
nil,
)
if err != nil {
return "", fmt.Errorf("encryption failed: %w", err)
}
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// BootstrapCredentials checks if credentials are in DB, if not, migrates from Env
func (s *CredentialsService) BootstrapCredentials(ctx context.Context) error {
// List of services and their env mapping
services := map[string]func() interface{}{
"stripe": func() interface{} {
return map[string]string{
"secretKey": os.Getenv("STRIPE_SECRET_KEY"),
"webhookSecret": os.Getenv("STRIPE_WEBHOOK_SECRET"),
"publishableKey": os.Getenv("STRIPE_PUBLISHABLE_KEY"),
}
},
"storage": func() interface{} {
return map[string]string{
"endpoint": os.Getenv("AWS_ENDPOINT"),
"accessKey": os.Getenv("AWS_ACCESS_KEY_ID"),
"secretKey": os.Getenv("AWS_SECRET_ACCESS_KEY"),
"bucket": os.Getenv("S3_BUCKET"),
"region": os.Getenv("AWS_REGION"),
}
},
"lavinmq": func() interface{} {
return map[string]string{
"amqpUrl": os.Getenv("AMQP_URL"),
}
},
"cloudflare_config": func() interface{} {
return map[string]string{
"apiToken": os.Getenv("CLOUDFLARE_API_TOKEN"),
"zoneId": os.Getenv("CLOUDFLARE_ZONE_ID"),
}
},
"cpanel": func() interface{} {
return map[string]string{
"host": os.Getenv("CPANEL_HOST"),
"username": os.Getenv("CPANEL_USERNAME"),
"apiToken": os.Getenv("CPANEL_API_TOKEN"),
}
},
"appwrite": func() interface{} {
return map[string]string{
"endpoint": os.Getenv("APPWRITE_ENDPOINT"),
"projectId": os.Getenv("APPWRITE_PROJECT_ID"),
"apiKey": os.Getenv("APPWRITE_API_KEY"),
}
},
"fcm_service_account": func() interface{} {
return map[string]string{
"serviceAccountJson": os.Getenv("FCM_SERVICE_ACCOUNT_JSON"),
}
},
"smtp": func() interface{} {
return map[string]string{
"host": os.Getenv("SMTP_HOST"),
"port": os.Getenv("SMTP_PORT"),
"username": os.Getenv("SMTP_USERNAME"),
"password": os.Getenv("SMTP_PASSWORD"),
"from_email": os.Getenv("SMTP_FROM_EMAIL"),
"from_name": os.Getenv("SMTP_FROM_NAME"),
"secure": os.Getenv("SMTP_SECURE"),
}
},
}
for service, getEnvData := range services {
// Check if already configured
configured, err := s.isServiceConfigured(ctx, service)
if err != nil {
fmt.Printf("[CredentialsBootstrap] Error checking %s: %v\n", service, err)
continue
}
if !configured {
data := getEnvData()
// Validate if env vars exist (naive check: at least one field not empty)
hasData := false
jsonBytes, _ := json.Marshal(data)
var stringMap map[string]string
json.Unmarshal(jsonBytes, &stringMap)
for _, v := range stringMap {
if v != "" {
hasData = true
break
}
}
if hasData {
fmt.Printf("[CredentialsBootstrap] Migrating %s from Env to DB...\n", service)
encrypted, err := s.EncryptPayload(string(jsonBytes))
if err != nil {
fmt.Printf("[CredentialsBootstrap] Failed to encrypt %s: %v\n", service, err)
continue
}
if err := s.SaveCredentials(ctx, service, encrypted, ""); err != nil {
fmt.Printf("[CredentialsBootstrap] Failed to save %s: %v\n", service, err)
} else {
fmt.Printf("[CredentialsBootstrap] Successfully migrated %s\n", service)
}
}
}
}
return nil
}
func (s *CredentialsService) isServiceConfigured(ctx context.Context, serviceName string) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM external_services_credentials WHERE service_name = $1)`
err := s.DB.QueryRowContext(ctx, query, serviceName).Scan(&exists)
return exists, err
}
func getEnvRSAKey() string {
key := os.Getenv("RSA_PRIVATE_KEY_BASE64")
if key == "" {
return "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQ200OGRqeTQyRzFuUWYKQ2dUSVlVdjlleFhnWTZld2JUZ2pPRG9sVnQ1MGxqSjV5YkdaSzNMcUF6eVpURmQvS0ZRL0hFTzBmZkhOOHJqMApOVkh3QXBzSXp2RUFpeHBMTTNiMUhYaFZnWW14NDhCWUJwZlE4dDN4cWdqTkUrWEFVUEdFR25zb1ducmJQV01EClJWQkFKVG1aOWZNSlc1OU1jdEdCU2t5eGlDODg1aUdnNFM2ZHdQdmNpUU0yRkhZcmlEWEtrNmJVMnhBQ2F0aUEKMEF3czgzSnpNS3dEKzJwckxMaDZ5VE9GdExrQ0tBdFloTS9GNHYxWE1xLzduODh5WC96Qk1zREhXRnRiOVZqdApWS1VNZnJ6MGFXRitHOE4rSVRmREVUTjBiTitrMTlqWDFOS2JRU1FKRE9FQ1FiMDFvb01MTi9YWldmOG9YRkZOCmgvY1Vpcmg5QWdNQkFBRUNnZ0VBRjlVcGNUL3RXeGNmQ0J1M0tTSno1cVFBTU1ZcWVWQnZsdC85dGIxZEVVc3QKdENTd2Z3NHNYK3pNWjV2MlZzdGNsSktsdkkzTHpUeGZXMGlPQmZkcFNtMjdEdmZPYm5UUUJSc0xSekt6aGpyZgpDZk1QRlNESnZ5d29DNWxPMHhMOEdvQnBuQTZud3MxV2FXNHBrcTkrTlZWaE9ySGg0dG4ydno3c0N1SkcxS3hSCmEzcFByMFQxUlFIUzA2RVUxR3dXZm1WcDRTT2RLdDVXRWhuK0YrL0FXWWo3QXlJMFljc3hSTmJDckkxYjYyWW0KSno1K3NZNmVvL3h5cUExS3hZNkl5YVJ4YXIrVlU0ek1QcVkvc1VOQlByQ0N5T28wMFlrQWJMZUp2V2tzVlJjZQp4N2hXUEhCRGk4YTg2QlkxYjlLL2xDT2lSK1JOdGpUaVVmU2VCWnl2UVFLQmdRRGQrL1k4M3RnOWczZzNzejI4CnRoUDVhZEE5TWljZ0hoUG5EdHhrZTh4MWxHOWV3TUdYNUVLUFErV05qclZTTjBKY3A5RjNaeWdodm1ZN1k5eisKc3d1YUdSc1ZuZUEwQ0Q0cFcySk5FVWtBQ0Y4WFdUdmF2ZmJlQnNCaDJOcVB5ckg0L2RSS0ozdHFDeHlNcHl6cwpWaks4ejFodWFpS3FnQ1I4SmRDeUNCSFFMUUtCZ1FEQWRvK3BhRmgzblBRK0cvY2dLQ2ZlR3VmSStXWkZ4SzJTCnAxOEMrTm1ENStIUFdoMWNYRTlQM1NSaE1LRlpDbHJuV3dvY21mNzlWWmJNUmlQWGN3TWk4aUcwcGZ0V2x3ZGQKTmVxZTVRQWNqUVYyWTk2aGdKTGxhTldVVm15MVJXcFg2T2dOajAyelVRN1BVRnNMaEh0T2RPWXFGeVpUNTVSYQp4OWdZSjlWcmtRS0JnQ0dNd0RXSTlLT3ZhQTc3RHh5alpEZHc3NkVnSUZ1eFVBNis1ejVrbTQwMXh2TktMTGc1Ckxub2FwK29TSklOdGlLRWFXQVUwMlJMb2hPajYrZDZnenVNV1lrcU5GdUttVUViTjRmaUY0VU9aQUU0MkZWN0YKRVRlVFM2WStNU0pFWDB1amlWOC93bDVQbVp3RWREeXY5bkVrNFZlbXdPQ0dCMzJmOVgvQ3luWnRBb0dBZlVTdAovUFdObjB6cExBUEh0WVp0YklMV21saTUyRzlMQ2trbDdpbEthakJqS1RMZUtWOXJ2KytQM2pKbzBpdUxQMHBpCktudVJIQks1TS92ekdDZ2p3bnNXdFIzVG1XaHp3cGQxUGphTy9BWk5wK0VZNXNWbzF5aUUyeWZsV1piMHdJTTMKaHB2ZlZ2ZExUR1JnM2Y0OHc3UVNteEsyUDZaYlNUc0p5NjhobWdFQ2dZRUFsemxCSEZ4RHBDYitKNmNpeUlhTApHU0pob3diODVKSTl5cXB0QW1FU0hNM3pnek8yRFhnSVZFYUFuWmN2N052dXQrNU54amszNVFOdmwwczFIL0RTCmRGcWVGVzJSdnpqZzVoQitqa3RjQjJ0N3pBZkdWNVc4UzlXRUVMMkJuYU5reGVTRGtyOVdIdXlEclpjRU90aG4KcEF3bEtDZWQzT21oUWYvcnB4NlZXcVk9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"
}
return key
}