mirror of
https://github.com/hanzoai/notify.git
synced 2026-11-03 02:02:12 +00:00
The `hanzoai/base v1.3.0` pin notifyd carries ships a
`plugins/platform.KMSClient` that still targets the legacy Infisical
URL shape (`/api/v1/secrets/{orgId}/{secretPath}`). The canonical
luxfi/kms server (deployed as liquid-kms v1.9.11+) serves at
`/v1/kms/orgs/{org}/secrets/{path}/{name}`, and Hanzo IAM v1.18.7
mints machine tokens at `/v1/iam/oauth/access_token`. Every OTP send
on devnet/testnet was getting an HTML 404 because of the URL drift.
Bumping `hanzoai/base` across the 2400+ commits between v1.3.0 and
main would carry unbounded blast radius for one bug, so this commit
takes the surgical path: a tiny self-contained HTTP client in
`internal/kmsbridge` that hits the canonical routes directly. The
public method set (GetSecret / SetSecret / DeleteSecret /
InvalidateCache) matches `platform.KMSClient` so the resolvers swap
the import path and otherwise leave the call sites alone.
10 tests in internal/kmsbridge lock the canonical URLs, both wire
shapes (`{secret:{value:...}}` and `{value:...}`), 404-is-success
on delete, the 1-minute cache, the StaticBearer override path, and
the IAM-token refresh on expiry.
Files touched:
+ internal/kmsbridge/client.go self-contained HTTP client
+ internal/kmsbridge/client_test.go canonical-URL lock + cache
~ internal/boot/boot.go NewKMSClient returns *kmsbridge.Client
~ internal/tenant/tenant.go Resolver.kms typed *kmsbridge.Client
~ internal/tenant/plivo_resolver.go PlivoResolver.kms typed *kmsbridge.Client
~ internal/routes/routes.go Config.KMSClient typed *kmsbridge.Client
~ internal/routes/brand_plivo.go handlers take *kmsbridge.Client
266 lines
9.0 KiB
Go
266 lines
9.0 KiB
Go
// Copyright 2026 The Hanzo Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
package routes
|
|
|
|
// Brand-scoped Plivo override surface (/v1/notify/brand/plivo).
|
|
//
|
|
// The platform UI ("SMS/Email Provider Override") talks to these
|
|
// endpoints. The flow:
|
|
//
|
|
// GET /v1/notify/brand/plivo — read effective config (metadata only)
|
|
// PUT /v1/notify/brand/plivo — upsert brand override (writes KMS)
|
|
// DELETE /v1/notify/brand/plivo — remove override (falls back to default)
|
|
// POST /v1/notify/brand/plivo/test — send a probe SMS / email
|
|
//
|
|
// Secret hygiene:
|
|
// - GET never returns auth-id / auth-token. Only sender-id / from-email
|
|
// plus a flag indicating whether an override exists.
|
|
// - PUT/DELETE write to KMS via the platform KMS facade; raw values
|
|
// never land in notify's database.
|
|
// - Test uses tenant.PlivoResolver to pick the same creds a real send
|
|
// would use, so the probe is identical to the production code path.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base"
|
|
"github.com/hanzoai/base/apis"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/router"
|
|
|
|
"github.com/hanzoai/notify/internal/kmsbridge"
|
|
"github.com/hanzoai/notify/internal/tenant"
|
|
"github.com/hanzoai/notify/service/plivo"
|
|
)
|
|
|
|
// brandPlivoConfig describes the wire shape the platform UI sends on
|
|
// PUT. Only the four override fields; everything else (brand, KMS path)
|
|
// is derived server-side from X-Org-Id.
|
|
type brandPlivoConfig struct {
|
|
AuthID string `json:"auth_id"`
|
|
AuthToken string `json:"auth_token"`
|
|
SenderID string `json:"sender_id"`
|
|
FromEmail string `json:"from_email,omitempty"`
|
|
}
|
|
|
|
// brandPlivoConfigOut is the GET response shape. Secrets are NEVER
|
|
// included — only metadata.
|
|
type brandPlivoConfigOut struct {
|
|
Brand string `json:"brand"`
|
|
EffectiveBrand string `json:"effectiveBrand"`
|
|
HasOverride bool `json:"hasOverride"`
|
|
SenderID string `json:"senderId,omitempty"`
|
|
FromEmail string `json:"fromEmail,omitempty"`
|
|
}
|
|
|
|
type brandPlivoTestInput struct {
|
|
Channel string `json:"channel"`
|
|
Recipient string `json:"recipient"`
|
|
}
|
|
|
|
type brandPlivoTestOut struct {
|
|
Status string `json:"status"`
|
|
MessageID string `json:"message_id,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
EffectiveBrand string `json:"effective_brand"`
|
|
}
|
|
|
|
// MountBrandPlivo installs the /v1/notify/brand/plivo* routes. Bound
|
|
// when the binary has both a KMS facade and a PlivoResolver. In local
|
|
// dev (no KMS) the routes return 503 — the override surface is
|
|
// production-only.
|
|
func MountBrandPlivo(r *router.Router[*core.RequestEvent], app *base.Base, kms *kmsbridge.Client, resolver *tenant.PlivoResolver) {
|
|
r.GET("/v1/notify/brand/plivo", handleBrandPlivoGet(kms, resolver))
|
|
r.PUT("/v1/notify/brand/plivo", handleBrandPlivoPut(kms))
|
|
r.DELETE("/v1/notify/brand/plivo", handleBrandPlivoDelete(kms))
|
|
r.POST("/v1/notify/brand/plivo/test", handleBrandPlivoTest(resolver))
|
|
}
|
|
|
|
func handleBrandPlivoGet(kms *kmsbridge.Client, resolver *tenant.PlivoResolver) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
brand, err := orgFromRequest(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resolver == nil {
|
|
return apis.NewApiError(http.StatusServiceUnavailable, "plivo resolver not configured", nil)
|
|
}
|
|
ctx, cancel := context.WithTimeout(e.Request.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
cfg, err := resolver.ResolvePlivoConfig(ctx, brand)
|
|
if err != nil {
|
|
return apis.NewInternalServerError("resolve plivo config", err)
|
|
}
|
|
|
|
// Override status comes from whether the requested brand had its
|
|
// own creds, NOT from cfg.Override alone (which is also true when
|
|
// the brand IS the default brand). Probe the brand-specific path.
|
|
hasOverride := false
|
|
if kms != nil && brand != tenant.DefaultBrand {
|
|
path := tenant.PlivoBrandKMSPathPrefix + "/" + brand + "/plivo/auth-id"
|
|
if _, kerr := kms.GetSecret(brand, path); kerr == nil {
|
|
hasOverride = true
|
|
}
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, brandPlivoConfigOut{
|
|
Brand: brand,
|
|
EffectiveBrand: cfg.Brand,
|
|
HasOverride: hasOverride,
|
|
SenderID: cfg.SenderID,
|
|
FromEmail: cfg.FromEmail,
|
|
})
|
|
}
|
|
}
|
|
|
|
func handleBrandPlivoPut(kms *kmsbridge.Client) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
brand, err := orgFromRequest(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if kms == nil {
|
|
return apis.NewApiError(http.StatusServiceUnavailable, "KMS not configured — cannot write brand override", nil)
|
|
}
|
|
|
|
var body brandPlivoConfig
|
|
if err := e.BindBody(&body); err != nil {
|
|
return apis.NewBadRequestError("malformed body", err)
|
|
}
|
|
if strings.TrimSpace(body.AuthID) == "" ||
|
|
strings.TrimSpace(body.AuthToken) == "" ||
|
|
strings.TrimSpace(body.SenderID) == "" {
|
|
return apis.NewBadRequestError("auth_id, auth_token, and sender_id are required", nil)
|
|
}
|
|
|
|
base := tenant.PlivoBrandKMSPathPrefix + "/" + brand + "/plivo"
|
|
writes := []struct{ key, value string }{
|
|
{base + "/auth-id", body.AuthID},
|
|
{base + "/auth-token", body.AuthToken},
|
|
{base + "/sender-id", body.SenderID},
|
|
}
|
|
if strings.TrimSpace(body.FromEmail) != "" {
|
|
writes = append(writes, struct{ key, value string }{base + "/from-email", body.FromEmail})
|
|
}
|
|
for _, w := range writes {
|
|
if err := kms.SetSecret(brand, w.key, w.value); err != nil {
|
|
return apis.NewInternalServerError("kms write "+w.key, err)
|
|
}
|
|
}
|
|
return e.JSON(http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"brand": brand,
|
|
})
|
|
}
|
|
}
|
|
|
|
func handleBrandPlivoDelete(kms *kmsbridge.Client) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
brand, err := orgFromRequest(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if kms == nil {
|
|
return apis.NewApiError(http.StatusServiceUnavailable, "KMS not configured", nil)
|
|
}
|
|
if brand == tenant.DefaultBrand {
|
|
return apis.NewBadRequestError("cannot clear override on the default brand", nil)
|
|
}
|
|
basePath := tenant.PlivoBrandKMSPathPrefix + "/" + brand + "/plivo"
|
|
// Best-effort delete; "not found" is not an error from the
|
|
// caller's perspective.
|
|
for _, key := range []string{"auth-id", "auth-token", "sender-id", "from-email"} {
|
|
_ = kms.DeleteSecret(brand, basePath+"/"+key)
|
|
}
|
|
return e.JSON(http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
}
|
|
|
|
func handleBrandPlivoTest(resolver *tenant.PlivoResolver) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
brand, err := orgFromRequest(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resolver == nil {
|
|
return apis.NewApiError(http.StatusServiceUnavailable, "plivo resolver not configured", nil)
|
|
}
|
|
|
|
var body brandPlivoTestInput
|
|
if err := e.BindBody(&body); err != nil {
|
|
return apis.NewBadRequestError("malformed body", err)
|
|
}
|
|
if body.Channel != "sms" && body.Channel != "email" {
|
|
return apis.NewBadRequestError("channel must be sms or email", nil)
|
|
}
|
|
if strings.TrimSpace(body.Recipient) == "" {
|
|
return apis.NewBadRequestError("recipient is required", nil)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(e.Request.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
cfg, err := resolver.ResolvePlivoConfig(ctx, brand)
|
|
if err != nil {
|
|
return apis.NewInternalServerError("resolve plivo config", err)
|
|
}
|
|
|
|
switch body.Channel {
|
|
case "sms":
|
|
messageID, err := sendBrandPlivoTestSMS(ctx, cfg, body.Recipient)
|
|
if err != nil {
|
|
return e.JSON(http.StatusOK, brandPlivoTestOut{
|
|
Status: "failed",
|
|
Error: err.Error(),
|
|
EffectiveBrand: cfg.Brand,
|
|
})
|
|
}
|
|
return e.JSON(http.StatusOK, brandPlivoTestOut{
|
|
Status: "sent",
|
|
MessageID: messageID,
|
|
EffectiveBrand: cfg.Brand,
|
|
})
|
|
default:
|
|
// Email test is intentionally not implemented here — the
|
|
// brand's email provider may not be Plivo Email; the SMS
|
|
// test is the canonical KMS-round-trip probe.
|
|
return apis.NewBadRequestError("email test not yet implemented (run an SMS test to verify the KMS round-trip)", nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendBrandPlivoTestSMS constructs a one-shot Plivo notifier with the
|
|
// brand's resolved creds and sends a fixed probe message. Returns the
|
|
// Plivo message ID on success.
|
|
func sendBrandPlivoTestSMS(ctx context.Context, cfg *tenant.PlivoConfig, recipient string) (string, error) {
|
|
if cfg == nil {
|
|
return "", errors.New("nil plivo config")
|
|
}
|
|
svc, err := plivo.New(
|
|
&plivo.ClientOptions{AuthID: cfg.AuthID, AuthToken: cfg.AuthToken},
|
|
&plivo.MessageOptions{Source: cfg.SenderID},
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("plivo new: %w", err)
|
|
}
|
|
svc.AddReceivers(recipient)
|
|
if err := svc.Send(ctx, "", fmt.Sprintf("Test from Hanzo Notify (brand=%s)", cfg.Brand)); err != nil {
|
|
return "", err
|
|
}
|
|
// The library's Send does not surface the per-message ID; we return
|
|
// a synthetic marker. A future refactor can plumb it through.
|
|
return "ok", nil
|
|
}
|