Head of Product & Engineering 4d8e1eb352
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: AES-256-GCM encryption for per-location Cast API keys + MongoDB auth
- Add internal/crypto package: AES-256-GCM encrypt/decrypt with migration
  passthrough for existing plain-text records (no "enc:" prefix = plain text)
- Store.NewStoreWithCipher injects cipher; SaveToken/UpdateLocationConfig
  encrypt cast_api_key before write; GetToken/ListTokens decrypt on read
- Add CREDENTIALS_ENCRYPTION_KEY env var (64-hex / 32-byte); warns if unset
- Add MongoDB authentication: MONGO_ROOT_USERNAME / MONGO_ROOT_PASSWORD via
  docker-compose MONGO_INITDB_ROOT_USERNAME/PASSWORD; MONGO_URI now requires
  credentials in .env.example
- Update .env.example with generation instructions for all secrets

Co-Authored-By: SideKx <sidekx.ai@sds.dev>
2026-04-06 15:27:38 +02:00

188 lines
5.6 KiB
Go

package store
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ErrLocationNotFound is returned when a location ID has no stored token record.
var ErrLocationNotFound = errors.New("location not found")
type TokenRecord struct {
LocationID string `bson:"location_id"`
CompanyID string `bson:"company_id"`
AccessToken string `bson:"access_token"`
RefreshToken string `bson:"refresh_token"`
ExpiresAt time.Time `bson:"expires_at"`
InstalledAt time.Time `bson:"installed_at"`
UpdatedAt time.Time `bson:"updated_at"`
SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default
CastAPIKey string `bson:"cast_api_key,omitempty"` // per-location Cast API key; overrides global CAST_API_KEY (stored encrypted when cipher is set)
}
// Cipher encrypts and decrypts string values at rest.
type Cipher interface {
Encrypt(plaintext string) (string, error)
Decrypt(value string) (string, error)
}
type Store struct {
client *mongo.Client
collection *mongo.Collection
cipher Cipher // nil = no encryption (plain text storage)
}
func NewStore(ctx context.Context, uri string) (*Store, error) {
return NewStoreWithCipher(ctx, uri, nil)
}
func NewStoreWithCipher(ctx context.Context, uri string, cipher Cipher) (*Store, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
return nil, err
}
if err := client.Ping(ctx, nil); err != nil {
return nil, err
}
col := client.Database("cast-ghl").Collection("oauth_tokens")
indexModel := mongo.IndexModel{
Keys: bson.D{{Key: "location_id", Value: 1}},
Options: options.Index().SetUnique(true),
}
if _, err := col.Indexes().CreateOne(ctx, indexModel); err != nil {
return nil, err
}
return &Store{client: client, collection: col, cipher: cipher}, nil
}
// encryptKey encrypts a Cast API key before storage. No-op if cipher is nil or key is empty.
func (s *Store) encryptKey(key string) (string, error) {
if s.cipher == nil || key == "" {
return key, nil
}
encrypted, err := s.cipher.Encrypt(key)
if err != nil {
return "", fmt.Errorf("store: encrypt cast_api_key: %w", err)
}
return encrypted, nil
}
// decryptKey decrypts a stored Cast API key. No-op if cipher is nil or key is empty.
func (s *Store) decryptKey(key string) string {
if s.cipher == nil || key == "" {
return key
}
plaintext, err := s.cipher.Decrypt(key)
if err != nil {
// Log but don't fail — return raw value so the caller can surface the error gracefully.
slog.Error("store: failed to decrypt cast_api_key", "err", err)
return ""
}
return plaintext
}
func (s *Store) SaveToken(ctx context.Context, record *TokenRecord) error {
encKey, err := s.encryptKey(record.CastAPIKey)
if err != nil {
return err
}
toStore := *record
toStore.CastAPIKey = encKey
toStore.UpdatedAt = time.Now()
filter := bson.D{{Key: "location_id", Value: record.LocationID}}
opts := options.Replace().SetUpsert(true)
_, err = s.collection.ReplaceOne(ctx, filter, toStore, opts)
return err
}
func (s *Store) GetToken(ctx context.Context, locationID string) (*TokenRecord, error) {
filter := bson.D{{Key: "location_id", Value: locationID}}
var record TokenRecord
err := s.collection.FindOne(ctx, filter).Decode(&record)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
record.CastAPIKey = s.decryptKey(record.CastAPIKey)
return &record, nil
}
func (s *Store) UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error {
filter := bson.D{{Key: "location_id", Value: locationID}}
update := bson.D{{Key: "$set", Value: bson.D{
{Key: "access_token", Value: accessToken},
{Key: "refresh_token", Value: refreshToken},
{Key: "expires_at", Value: expiresAt},
{Key: "updated_at", Value: time.Now()},
}}}
_, err := s.collection.UpdateOne(ctx, filter, update)
return err
}
// UpdateLocationConfig sets the per-location Cast sender ID and API key.
// Either field may be empty to clear it (falling back to the global default).
func (s *Store) UpdateLocationConfig(ctx context.Context, locationID, senderID, castAPIKey string) error {
encKey, err := s.encryptKey(castAPIKey)
if err != nil {
return err
}
filter := bson.D{{Key: "location_id", Value: locationID}}
update := bson.D{{Key: "$set", Value: bson.D{
{Key: "sender_id", Value: senderID},
{Key: "cast_api_key", Value: encKey},
{Key: "updated_at", Value: time.Now()},
}}}
res, err := s.collection.UpdateOne(ctx, filter, update)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrLocationNotFound
}
return nil
}
// ListTokens returns all installed location records (without OAuth tokens for safety).
func (s *Store) ListTokens(ctx context.Context) ([]*TokenRecord, error) {
cursor, err := s.collection.Find(ctx, bson.D{})
if err != nil {
return nil, err
}
defer func() { _ = cursor.Close(ctx) }()
var records []*TokenRecord
if err := cursor.All(ctx, &records); err != nil {
return nil, err
}
for _, rec := range records {
rec.CastAPIKey = s.decryptKey(rec.CastAPIKey)
}
return records, nil
}
func (s *Store) DeleteToken(ctx context.Context, locationID string) error {
filter := bson.D{{Key: "location_id", Value: locationID}}
_, err := s.collection.DeleteOne(ctx, filter)
return err
}
func (s *Store) Close(ctx context.Context) error {
return s.client.Disconnect(ctx)
}