feat: AES-256-GCM encryption for per-location Cast API keys + MongoDB auth
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
This commit is contained in:
parent
dacaaa4c91
commit
4d8e1eb352
22
.env.example
22
.env.example
@ -1,10 +1,14 @@
|
|||||||
PORT=3002
|
PORT=3002
|
||||||
BASE_URL=https://hl.cast.ph
|
BASE_URL=https://hl.cast.ph
|
||||||
|
|
||||||
|
# nginx-proxy / Let's Encrypt
|
||||||
|
VIRTUAL_HOST=hl.cast.ph
|
||||||
|
LETSENCRYPT_EMAIL=ops@cast.ph
|
||||||
|
|
||||||
# GHL OAuth
|
# GHL OAuth
|
||||||
GHL_CLIENT_ID=
|
GHL_CLIENT_ID=
|
||||||
GHL_CLIENT_SECRET=
|
GHL_CLIENT_SECRET=
|
||||||
# RSA public key from GHL docs (static, not per-app). Paste the full PEM block.
|
# Ed25519 public key from GHL Marketplace app settings (PKIX PEM). Paste the full PEM block.
|
||||||
GHL_WEBHOOK_PUBLIC_KEY=
|
GHL_WEBHOOK_PUBLIC_KEY=
|
||||||
GHL_CONVERSATION_PROVIDER_ID=
|
GHL_CONVERSATION_PROVIDER_ID=
|
||||||
|
|
||||||
@ -13,8 +17,18 @@ CAST_API_KEY=
|
|||||||
CAST_API_URL=https://api.cast.ph
|
CAST_API_URL=https://api.cast.ph
|
||||||
CAST_SENDER_ID=
|
CAST_SENDER_ID=
|
||||||
|
|
||||||
# MongoDB
|
# MongoDB — use a strong password; URI must include auth credentials
|
||||||
MONGO_URI=mongodb://mongo:27017/cast-ghl
|
# Generate password: openssl rand -hex 24
|
||||||
|
MONGO_ROOT_USERNAME=castghl
|
||||||
|
MONGO_ROOT_PASSWORD=
|
||||||
|
MONGO_URI=mongodb://castghl:<password>@mongo:27017/cast-ghl?authSource=admin
|
||||||
|
|
||||||
# Inbound (Phase 2)
|
# AES-256 key for encrypting per-location Cast API keys at rest in MongoDB.
|
||||||
|
# Generate: openssl rand -hex 32
|
||||||
|
# WARNING: if this key is lost without migrating records first, per-location
|
||||||
|
# API keys stored in MongoDB will be unreadable. Back this up securely.
|
||||||
|
CREDENTIALS_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# Admin API shared secret (protects /api/admin/*)
|
||||||
|
# Generate: openssl rand -hex 32
|
||||||
INBOUND_API_KEY=
|
INBOUND_API_KEY=
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"git.sds.dev/CAST/cast-ghl-plugin/internal/cast"
|
"git.sds.dev/CAST/cast-ghl-plugin/internal/cast"
|
||||||
"git.sds.dev/CAST/cast-ghl-plugin/internal/config"
|
"git.sds.dev/CAST/cast-ghl-plugin/internal/config"
|
||||||
|
"git.sds.dev/CAST/cast-ghl-plugin/internal/crypto"
|
||||||
"git.sds.dev/CAST/cast-ghl-plugin/internal/ghl"
|
"git.sds.dev/CAST/cast-ghl-plugin/internal/ghl"
|
||||||
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
|
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
|
||||||
)
|
)
|
||||||
@ -35,7 +36,15 @@ func run() error {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
s, err := store.NewStore(ctx, cfg.MongoURI)
|
var cipher *crypto.Cipher
|
||||||
|
if cfg.CredentialsEncryptionKey != "" {
|
||||||
|
cipher, err = crypto.NewCipher(cfg.CredentialsEncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("credentials cipher: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := store.NewStoreWithCipher(ctx, cfg.MongoURI, cipher)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mongodb: %w", err)
|
return fmt.Errorf("mongodb: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,13 +26,16 @@ services:
|
|||||||
mongo:
|
mongo:
|
||||||
image: mongo:7
|
image: mongo:7
|
||||||
# No ports exposed — only reachable by bridge on the internal network
|
# No ports exposed — only reachable by bridge on the internal network
|
||||||
|
environment:
|
||||||
|
- MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USERNAME}
|
||||||
|
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- mongo-data:/data/db
|
- mongo-data:/data/db
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')", "-u", "${MONGO_ROOT_USERNAME}", "-p", "${MONGO_ROOT_PASSWORD}", "--authenticationDatabase", "admin"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -18,6 +19,10 @@ type Config struct {
|
|||||||
CastSenderID string
|
CastSenderID string
|
||||||
MongoURI string
|
MongoURI string
|
||||||
InboundAPIKey string
|
InboundAPIKey string
|
||||||
|
// CredentialsEncryptionKey is a 64-hex-char (32-byte) AES-256 key used to
|
||||||
|
// encrypt per-location Cast API keys at rest in MongoDB.
|
||||||
|
// Optional: if unset, keys are stored in plain text (not recommended for production).
|
||||||
|
CredentialsEncryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@ -33,6 +38,7 @@ func Load() (*Config, error) {
|
|||||||
CastSenderID: os.Getenv("CAST_SENDER_ID"),
|
CastSenderID: os.Getenv("CAST_SENDER_ID"),
|
||||||
MongoURI: os.Getenv("MONGO_URI"),
|
MongoURI: os.Getenv("MONGO_URI"),
|
||||||
InboundAPIKey: os.Getenv("INBOUND_API_KEY"),
|
InboundAPIKey: os.Getenv("INBOUND_API_KEY"),
|
||||||
|
CredentialsEncryptionKey: os.Getenv("CREDENTIALS_ENCRYPTION_KEY"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var missing []string
|
var missing []string
|
||||||
@ -53,6 +59,11 @@ func Load() (*Config, error) {
|
|||||||
if len(missing) > 0 {
|
if len(missing) > 0 {
|
||||||
return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
|
return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.CredentialsEncryptionKey == "" {
|
||||||
|
slog.Warn("CREDENTIALS_ENCRYPTION_KEY is not set — per-location Cast API keys will be stored in plain text")
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
internal/crypto/aes.go
Normal file
83
internal/crypto/aes.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const encPrefix = "enc:"
|
||||||
|
|
||||||
|
// Cipher encrypts and decrypts short string values using AES-256-GCM.
|
||||||
|
// The encrypted format is: "enc:" + hex(nonce + ciphertext + tag).
|
||||||
|
//
|
||||||
|
// Values that do not start with "enc:" are treated as plain text —
|
||||||
|
// this allows safe migration of existing unencrypted records.
|
||||||
|
type Cipher struct {
|
||||||
|
gcm cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCipher creates a Cipher from a 64-hex-character (32-byte) key.
|
||||||
|
func NewCipher(keyHex string) (*Cipher, error) {
|
||||||
|
key, err := hex.DecodeString(keyHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("crypto: invalid hex key: %w", err)
|
||||||
|
}
|
||||||
|
if len(key) != 32 {
|
||||||
|
return nil, fmt.Errorf("crypto: key must be 32 bytes (64 hex chars), got %d bytes", len(key))
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("crypto: failed to create AES cipher: %w", err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("crypto: failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
return &Cipher{gcm: gcm}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt encrypts plaintext and returns "enc:<hex(nonce+ciphertext+tag)>".
|
||||||
|
// Empty input is returned as-is (no encryption needed for empty/unset fields).
|
||||||
|
func (c *Cipher) Encrypt(plaintext string) (string, error) {
|
||||||
|
if plaintext == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
nonce := make([]byte, c.gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", fmt.Errorf("crypto: failed to generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
ciphertext := c.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return encPrefix + hex.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts a value produced by Encrypt.
|
||||||
|
// If the value does not start with "enc:", it is returned unchanged (plain text migration path).
|
||||||
|
func (c *Cipher) Decrypt(value string) (string, error) {
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(value, encPrefix) {
|
||||||
|
// Plain text — not yet encrypted; return as-is.
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
data, err := hex.DecodeString(strings.TrimPrefix(value, encPrefix))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("crypto: malformed ciphertext hex: %w", err)
|
||||||
|
}
|
||||||
|
nonceSize := c.gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return "", errors.New("crypto: ciphertext too short")
|
||||||
|
}
|
||||||
|
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||||
|
plaintext, err := c.gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("crypto: decryption failed: %w", err)
|
||||||
|
}
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
94
internal/crypto/aes_test.go
Normal file
94
internal/crypto/aes_test.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testKeyHex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
|
||||||
|
|
||||||
|
func TestEncryptDecryptRoundtrip(t *testing.T) {
|
||||||
|
c, err := NewCipher(testKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCipher: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := "cast_abc123def456"
|
||||||
|
encrypted, err := c.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(encrypted, encPrefix) {
|
||||||
|
t.Errorf("encrypted value missing %q prefix: %q", encPrefix, encrypted)
|
||||||
|
}
|
||||||
|
if encrypted == plaintext {
|
||||||
|
t.Errorf("encrypted value equals plaintext — no encryption occurred")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := c.Decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if decrypted != plaintext {
|
||||||
|
t.Errorf("roundtrip mismatch: got %q, want %q", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptProducesUniqueNonce(t *testing.T) {
|
||||||
|
c, err := NewCipher(testKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCipher: %v", err)
|
||||||
|
}
|
||||||
|
a, _ := c.Encrypt("same-value")
|
||||||
|
b, _ := c.Encrypt("same-value")
|
||||||
|
if a == b {
|
||||||
|
t.Error("two encryptions of the same plaintext produced identical ciphertext (nonce reuse)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptPlainText(t *testing.T) {
|
||||||
|
c, err := NewCipher(testKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCipher: %v", err)
|
||||||
|
}
|
||||||
|
// Plain text (no "enc:" prefix) should pass through unchanged for migration.
|
||||||
|
plain := "legacy_unencrypted_key"
|
||||||
|
result, err := c.Decrypt(plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt plain text: %v", err)
|
||||||
|
}
|
||||||
|
if result != plain {
|
||||||
|
t.Errorf("plain text passthrough: got %q, want %q", result, plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptEmptyString(t *testing.T) {
|
||||||
|
c, err := NewCipher(testKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCipher: %v", err)
|
||||||
|
}
|
||||||
|
enc, err := c.Encrypt("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt empty: %v", err)
|
||||||
|
}
|
||||||
|
if enc != "" {
|
||||||
|
t.Errorf("encrypt empty: expected empty result, got %q", enc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCipherInvalidKey(t *testing.T) {
|
||||||
|
cases := []struct{ name, key string }{
|
||||||
|
{"not hex", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"},
|
||||||
|
{"too short", "0102030405060708"},
|
||||||
|
{"too long", testKeyHex + "ff"},
|
||||||
|
{"empty", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := NewCipher(tc.key)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error for key %q, got nil", tc.key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
@ -22,15 +24,26 @@ type TokenRecord struct {
|
|||||||
InstalledAt time.Time `bson:"installed_at"`
|
InstalledAt time.Time `bson:"installed_at"`
|
||||||
UpdatedAt time.Time `bson:"updated_at"`
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default
|
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
|
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 {
|
type Store struct {
|
||||||
client *mongo.Client
|
client *mongo.Client
|
||||||
collection *mongo.Collection
|
collection *mongo.Collection
|
||||||
|
cipher Cipher // nil = no encryption (plain text storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStore(ctx context.Context, uri string) (*Store, error) {
|
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)
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@ -52,14 +65,47 @@ func NewStore(ctx context.Context, uri string) (*Store, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Store{client: client, collection: col}, nil
|
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 {
|
func (s *Store) SaveToken(ctx context.Context, record *TokenRecord) error {
|
||||||
record.UpdatedAt = time.Now()
|
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}}
|
filter := bson.D{{Key: "location_id", Value: record.LocationID}}
|
||||||
opts := options.Replace().SetUpsert(true)
|
opts := options.Replace().SetUpsert(true)
|
||||||
_, err := s.collection.ReplaceOne(ctx, filter, record, opts)
|
_, err = s.collection.ReplaceOne(ctx, filter, toStore, opts)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +119,7 @@ func (s *Store) GetToken(ctx context.Context, locationID string) (*TokenRecord,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
record.CastAPIKey = s.decryptKey(record.CastAPIKey)
|
||||||
return &record, nil
|
return &record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,10 +138,15 @@ func (s *Store) UpdateToken(ctx context.Context, locationID, accessToken, refres
|
|||||||
// UpdateLocationConfig sets the per-location Cast sender ID and API key.
|
// 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).
|
// 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 {
|
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}}
|
filter := bson.D{{Key: "location_id", Value: locationID}}
|
||||||
update := bson.D{{Key: "$set", Value: bson.D{
|
update := bson.D{{Key: "$set", Value: bson.D{
|
||||||
{Key: "sender_id", Value: senderID},
|
{Key: "sender_id", Value: senderID},
|
||||||
{Key: "cast_api_key", Value: castAPIKey},
|
{Key: "cast_api_key", Value: encKey},
|
||||||
{Key: "updated_at", Value: time.Now()},
|
{Key: "updated_at", Value: time.Now()},
|
||||||
}}}
|
}}}
|
||||||
res, err := s.collection.UpdateOne(ctx, filter, update)
|
res, err := s.collection.UpdateOne(ctx, filter, update)
|
||||||
@ -118,6 +170,9 @@ func (s *Store) ListTokens(ctx context.Context) ([]*TokenRecord, error) {
|
|||||||
if err := cursor.All(ctx, &records); err != nil {
|
if err := cursor.All(ctx, &records); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
for _, rec := range records {
|
||||||
|
rec.CastAPIKey = s.decryptKey(rec.CastAPIKey)
|
||||||
|
}
|
||||||
return records, nil
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user