diff --git a/FUTURE_DEV.md b/FUTURE_DEV.md new file mode 100644 index 0000000..cfd4310 --- /dev/null +++ b/FUTURE_DEV.md @@ -0,0 +1,75 @@ +# Future Development Notes + +This document tracks planned improvements, technical debt with business impact, and migration paths +for features currently implemented with known limitations. + +--- + +## Upgrade: Per-location Cast API keys from MongoDB → Infisical + +### Current state (Option A) + +Per-location Cast API keys (`cast_api_key`) and sender IDs (`sender_id`) are stored in MongoDB +alongside OAuth tokens in the `cast-ghl.oauth_tokens` collection. + +**Limitation:** Secret material (Cast API keys) stored in the same database as operational data. +If MongoDB is compromised, all per-location Cast credentials are exposed. MongoDB auth and TLS +mitigate this but do not eliminate the risk. + +### Target state (Option C) + +Use **[Infisical](https://github.com/Infisical/infisical)** as the secrets backend for per-location +Cast API keys. Infisical is open-source, self-hostable, and provides secret versioning, audit logs, +access policies, and dynamic secrets. + +### Migration plan + +#### 1. Deploy Infisical (self-hosted or Infisical Cloud) +- Recommended: self-host on the same Vultr instance or a dedicated secrets VM +- Create a project: `cast-ghl-provider` +- Create an environment: `production` +- Create a machine identity (service account) for the bridge service + +#### 2. Secret naming convention +Store each location's API key as a secret named: +``` +CAST_API_KEY_ +``` +Example: `CAST_API_KEY_q5LZDBHiJ9BsY9Vge5De` + +#### 3. Code changes in the bridge +- Add `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` env vars to config +- Add Infisical Go SDK: `github.com/infisical/go-sdk` +- Create `internal/secrets/infisical.go` — wraps the SDK with a `GetLocationAPIKey(locationID) (string, error)` method and an in-process cache (TTL ~60s to avoid hammering the API) +- In `processOutbound`: call `secrets.GetLocationAPIKey(locationID)` instead of reading from the token record +- The `cast_api_key` field in `TokenRecord` can be kept as a fallback during migration, then removed + +#### 4. Admin API changes +- `PUT /api/admin/locations/{locationId}/config` should write the API key to Infisical (not MongoDB) +- `GET` endpoints should read from Infisical and mask the returned value + +#### 5. Migration of existing keys +1. For each location with a `cast_api_key` in MongoDB, write it to Infisical via the admin API +2. Verify the bridge reads the key correctly from Infisical +3. Clear `cast_api_key` from MongoDB records +4. Remove the MongoDB field once fully migrated + +#### 6. MongoDB security hardening (do regardless of Infisical migration) +- Ensure `MONGO_URI` uses authentication: `mongodb://user:pass@host:27017/cast-ghl?authSource=admin` +- Enable TLS: append `?tls=true&tlsCAFile=/path/to/ca.pem` to the URI +- Restrict MongoDB network access to the bridge server IP only (Vultr firewall rules) +- Enable MongoDB audit logging for the `cast-ghl` database + +--- + +## Other Future Items + +### Inbound SMS (2-way) — Cast SIM gateway webhook +- Tracked in CASA-61 +- Requires Cast SIM gateway to support outbound webhooks +- Bridge handler stub already exists (`PostInboundMessage` in `internal/ghl/api.go`) +- Need to agree on Cast webhook payload format and DID → locationId mapping + +### Token refresh proactive scheduling +- Currently tokens are refreshed on-demand (when expired at webhook time) +- A background goroutine that refreshes tokens 1 hour before expiry would reduce latency on the first webhook after a 24-hour token lifetime diff --git a/cmd/server/main.go b/cmd/server/main.go index fc87266..d093b0b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -63,7 +63,9 @@ func run() error { r.Get("/oauth-callback", oauthHandler.HandleCallback) r.Post("/api/ghl/v1/webhook/messages", webhookHandler.HandleWebhook) r.Post("/api/ghl/v1/webhook/uninstall", webhookHandler.HandleUninstall) - r.Put("/api/admin/locations/{locationId}/sender-id", adminHandler.HandleSetSenderID) + r.Get("/api/admin/locations", adminHandler.HandleListLocations) + r.Get("/api/admin/locations/{locationId}/config", adminHandler.HandleGetLocationConfig) + r.Put("/api/admin/locations/{locationId}/config", adminHandler.HandleSetLocationConfig) srv := &http.Server{ Addr: ":" + cfg.Port, diff --git a/internal/cast/client.go b/internal/cast/client.go index b7ddbec..2b9effd 100644 --- a/internal/cast/client.go +++ b/internal/cast/client.go @@ -29,8 +29,9 @@ func NewClient(baseURL, apiKey, senderID string) *Client { } } -// SendSMS sends an SMS via Cast API. senderID overrides the client-level default when non-empty. -func (c *Client) SendSMS(ctx context.Context, to, message, senderID string) (*SendResponse, error) { +// SendSMS sends an SMS via Cast API. +// apiKey and senderID override client-level defaults when non-empty. +func (c *Client) SendSMS(ctx context.Context, to, message, apiKey, senderID string) (*SendResponse, error) { req := SendRequest{To: to, Message: message} switch { case senderID != "": @@ -47,8 +48,14 @@ func (c *Client) SendSMS(ctx context.Context, to, message, senderID string) (*Se const maxRetries = 3 backoff := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second} + // Resolve effective API key: per-call override takes precedence over client default. + effectiveAPIKey := c.apiKey + if apiKey != "" { + effectiveAPIKey = apiKey + } + for attempt := 0; attempt <= maxRetries; attempt++ { - resp, err := c.doRequest(ctx, body) + resp, err := c.doRequest(ctx, body, effectiveAPIKey) if err != nil { return nil, err } @@ -102,12 +109,12 @@ func (c *Client) SendSMS(ctx context.Context, to, message, senderID string) (*Se return nil, &CastAPIError{StatusCode: http.StatusTooManyRequests, APIError: "max retries exceeded"} } -func (c *Client) doRequest(ctx context.Context, body []byte) (*http.Response, error) { +func (c *Client) doRequest(ctx context.Context, body []byte, apiKey string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/sms/send", bytes.NewReader(body)) if err != nil { return nil, err } - req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("X-API-Key", apiKey) req.Header.Set("Content-Type", "application/json") return c.httpClient.Do(req) } diff --git a/internal/cast/client_test.go b/internal/cast/client_test.go index eddacc2..b11055c 100644 --- a/internal/cast/client_test.go +++ b/internal/cast/client_test.go @@ -32,7 +32,7 @@ func TestSendSMS_Success(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_testkey", "") - resp, err := client.SendSMS(context.Background(), "09171234567", "test message", "") + resp, err := client.SendSMS(context.Background(), "09171234567", "test message", "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -49,7 +49,7 @@ func TestSendSMS_APIError(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_testkey", "") - _, err := client.SendSMS(context.Background(), "09171234567", "test", "") + _, err := client.SendSMS(context.Background(), "09171234567", "test", "", "") if err == nil { t.Fatal("expected error, got nil") } @@ -70,7 +70,7 @@ func TestSendSMS_SuccessFalseInBody(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_testkey", "") - _, err := client.SendSMS(context.Background(), "09171234567", "test", "") + _, err := client.SendSMS(context.Background(), "09171234567", "test", "", "") if err == nil { t.Fatal("expected error, got nil") } @@ -96,7 +96,7 @@ func TestSendSMS_WithSenderID(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_testkey", "CAST") - _, err := client.SendSMS(context.Background(), "09171234567", "test", "") + _, err := client.SendSMS(context.Background(), "09171234567", "test", "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -115,7 +115,7 @@ func TestSendSMS_WithoutSenderID(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_testkey", "") - _, err := client.SendSMS(context.Background(), "09171234567", "test", "") + _, err := client.SendSMS(context.Background(), "09171234567", "test", "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -130,7 +130,7 @@ func TestSendSMS_Unauthorized(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_badkey", "") - _, err := client.SendSMS(context.Background(), "09171234567", "test", "") + _, err := client.SendSMS(context.Background(), "09171234567", "test", "", "") if err == nil { t.Fatal("expected error for 401, got nil") } @@ -158,7 +158,7 @@ func TestSendSMS_RetryOn429(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_testkey", "") - resp, err := client.SendSMS(context.Background(), "09171234567", "test", "") + resp, err := client.SendSMS(context.Background(), "09171234567", "test", "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/ghl/admin.go b/internal/ghl/admin.go index 4e79279..5e1ea92 100644 --- a/internal/ghl/admin.go +++ b/internal/ghl/admin.go @@ -6,52 +6,142 @@ import ( "io" "log/slog" "net/http" + "strings" "time" + "git.sds.dev/CAST/cast-ghl-plugin/internal/store" "github.com/go-chi/chi/v5" ) -// LocationConfigStore is the store interface used by AdminHandler. -type LocationConfigStore interface { - UpdateSenderID(ctx context.Context, locationID, senderID string) error +// AdminStore is the store interface used by AdminHandler. +type AdminStore interface { + GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error) + UpdateLocationConfig(ctx context.Context, locationID, senderID, castAPIKey string) error + ListTokens(ctx context.Context) ([]*store.TokenRecord, error) } -// AdminHandler exposes internal management endpoints. -// All routes require a valid Authorization: Bearer header. +// AdminHandler exposes management endpoints for the cast-backend admin portal. +// All routes require Authorization: Bearer . type AdminHandler struct { adminKey string - store LocationConfigStore + store AdminStore } -func NewAdminHandler(adminKey string, store LocationConfigStore) *AdminHandler { +func NewAdminHandler(adminKey string, store AdminStore) *AdminHandler { return &AdminHandler{adminKey: adminKey, store: store} } -// HandleSetSenderID sets or clears the per-location Cast sender ID. +func (h *AdminHandler) auth(r *http.Request) bool { + return h.adminKey != "" && r.Header.Get("Authorization") == "Bearer "+h.adminKey +} + +// locationConfigView is the JSON shape returned by GET endpoints. +// The Cast API key is masked to avoid leaking secrets over the wire. +type locationConfigView struct { + LocationID string `json:"location_id"` + CompanyID string `json:"company_id"` + SenderID string `json:"sender_id"` + CastAPIKeyMasked string `json:"cast_api_key"` + InstalledAt time.Time `json:"installed_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func maskAPIKey(key string) string { + if key == "" { + return "" + } + if len(key) <= 12 { + return strings.Repeat("*", len(key)) + } + return key[:12] + "..." +} + +func toView(r *store.TokenRecord) locationConfigView { + return locationConfigView{ + LocationID: r.LocationID, + CompanyID: r.CompanyID, + SenderID: r.SenderID, + CastAPIKeyMasked: maskAPIKey(r.CastAPIKey), + InstalledAt: r.InstalledAt, + UpdatedAt: r.UpdatedAt, + } +} + +// HandleListLocations returns all installed locations with their config. // -// PUT /api/admin/locations/{locationId}/sender-id -// Authorization: Bearer -// {"sender_id":"CAST"} -func (h *AdminHandler) HandleSetSenderID(w http.ResponseWriter, r *http.Request) { - if h.adminKey == "" || r.Header.Get("Authorization") != "Bearer "+h.adminKey { +// GET /api/admin/locations +func (h *AdminHandler) HandleListLocations(w http.ResponseWriter, r *http.Request) { + if !h.auth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + records, err := h.store.ListTokens(ctx) + if err != nil { + slog.Error("admin: list locations failed", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + views := make([]locationConfigView, 0, len(records)) + for _, rec := range records { + views = append(views, toView(rec)) + } + + writeJSON(w, http.StatusOK, views) +} + +// HandleGetLocationConfig returns the config for a single location. +// +// GET /api/admin/locations/{locationId}/config +func (h *AdminHandler) HandleGetLocationConfig(w http.ResponseWriter, r *http.Request) { + if !h.auth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } locationID := chi.URLParam(r, "locationId") - if locationID == "" { - http.Error(w, "missing locationId", http.StatusBadRequest) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + rec, err := h.store.GetToken(ctx, locationID) + if err != nil { + slog.Error("admin: get location config failed", "location_id", locationID, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if rec == nil { + http.Error(w, "location not found", http.StatusNotFound) return } - body, err := io.ReadAll(io.LimitReader(r.Body, 1024)) + writeJSON(w, http.StatusOK, toView(rec)) +} + +// HandleSetLocationConfig sets the sender ID and Cast API key for a location. +// +// PUT /api/admin/locations/{locationId}/config +// {"sender_id": "CAST", "cast_api_key": "cast_abc123..."} +func (h *AdminHandler) HandleSetLocationConfig(w http.ResponseWriter, r *http.Request) { + if !h.auth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + locationID := chi.URLParam(r, "locationId") + + body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) if err != nil { http.Error(w, "failed to read body", http.StatusBadRequest) return } var payload struct { - SenderID string `json:"sender_id"` + SenderID string `json:"sender_id"` + CastAPIKey string `json:"cast_api_key"` } if err := json.Unmarshal(body, &payload); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) @@ -61,8 +151,8 @@ func (h *AdminHandler) HandleSetSenderID(w http.ResponseWriter, r *http.Request) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - if err := h.store.UpdateSenderID(ctx, locationID, payload.SenderID); err != nil { - slog.Error("admin: failed to update sender_id", "location_id", locationID, "err", err) + if err := h.store.UpdateLocationConfig(ctx, locationID, payload.SenderID, payload.CastAPIKey); err != nil { + slog.Error("admin: update location config failed", "location_id", locationID, "err", err) if err.Error() == "location not found" { http.Error(w, "location not found", http.StatusNotFound) return @@ -71,8 +161,17 @@ func (h *AdminHandler) HandleSetSenderID(w http.ResponseWriter, r *http.Request) return } - slog.Info("admin: sender_id updated", "location_id", locationID, "sender_id", payload.SenderID) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) + slog.Info("admin: location config updated", "location_id", locationID, "sender_id", payload.SenderID, "cast_api_key_set", payload.CastAPIKey != "") + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + data, err := json.Marshal(v) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) } diff --git a/internal/ghl/oauth.go b/internal/ghl/oauth.go index 555fe54..b0507a2 100644 --- a/internal/ghl/oauth.go +++ b/internal/ghl/oauth.go @@ -28,7 +28,8 @@ type TokenStore interface { SaveToken(ctx context.Context, record *store.TokenRecord) error GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error) UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error - UpdateSenderID(ctx context.Context, locationID, senderID string) error + UpdateLocationConfig(ctx context.Context, locationID, senderID, castAPIKey string) error + ListTokens(ctx context.Context) ([]*store.TokenRecord, error) DeleteToken(ctx context.Context, locationID string) error } diff --git a/internal/ghl/oauth_test.go b/internal/ghl/oauth_test.go index c96586f..c2d9948 100644 --- a/internal/ghl/oauth_test.go +++ b/internal/ghl/oauth_test.go @@ -201,13 +201,21 @@ func (m *inMemStore) UpdateToken(_ context.Context, locationID, accessToken, ref return nil } -func (m *inMemStore) UpdateSenderID(_ context.Context, locationID, senderID string) error { +func (m *inMemStore) UpdateLocationConfig(_ context.Context, locationID, senderID, castAPIKey string) error { if m.token != nil && m.token.LocationID == locationID { m.token.SenderID = senderID + m.token.CastAPIKey = castAPIKey } return nil } +func (m *inMemStore) ListTokens(_ context.Context) ([]*store.TokenRecord, error) { + if m.token == nil { + return nil, nil + } + return []*store.TokenRecord{m.token}, nil +} + func (m *inMemStore) DeleteToken(_ context.Context, locationID string) error { if m.token != nil && m.token.LocationID == locationID { m.token = nil diff --git a/internal/ghl/webhook.go b/internal/ghl/webhook.go index cf92f8f..204dd85 100644 --- a/internal/ghl/webhook.go +++ b/internal/ghl/webhook.go @@ -138,14 +138,15 @@ func (h *WebhookHandler) processOutbound(webhook OutboundMessageWebhook) { return } - // Look up per-location sender ID; fall back to Cast client default if unset. - var senderID string + // Look up per-location Cast config; fall back to client defaults if unset. + var senderID, castAPIKey string if rec, err := h.store.GetToken(ctx, webhook.LocationID); err == nil && rec != nil { senderID = rec.SenderID + castAPIKey = rec.CastAPIKey } - slog.Info("webhook: calling cast api", "message_id", webhook.MessageID, "to", localPhone, "sender_id", senderID) - _, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message, senderID) + slog.Info("webhook: calling cast api", "message_id", webhook.MessageID, "to", localPhone, "sender_id", senderID, "per_location_key", castAPIKey != "") + _, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message, castAPIKey, senderID) if err != nil { slog.Error("webhook: cast send failed", "message_id", webhook.MessageID, "to", localPhone, "err", err) h.updateStatus(ctx, webhook, "failed") diff --git a/internal/store/mongo.go b/internal/store/mongo.go index d363a88..18d32fd 100644 --- a/internal/store/mongo.go +++ b/internal/store/mongo.go @@ -18,7 +18,8 @@ type TokenRecord struct { 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 + 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 } type Store struct { @@ -84,10 +85,13 @@ func (s *Store) UpdateToken(ctx context.Context, locationID, accessToken, refres return err } -func (s *Store) UpdateSenderID(ctx context.Context, locationID, senderID string) error { +// 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 { 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: castAPIKey}, {Key: "updated_at", Value: time.Now()}, }}} res, err := s.collection.UpdateOne(ctx, filter, update) @@ -100,6 +104,20 @@ func (s *Store) UpdateSenderID(ctx context.Context, locationID, senderID string) 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 + } + 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)