Files
zeekay 4e9881462c fix(kms): route through canonical /v1/kms/orgs/{org}/secrets URL
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 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
2026-06-02 11:35:58 -07:00

226 lines
8.1 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 tenant
// Multi-brand Plivo resolution.
//
// IAM lives at hanzo.id / lux.id / zoo.id / pars.id / hanzo.id; each
// hostname maps to a brand slug (the JWT owner claim → X-Org-Id header).
// Notify reads that brand slug and resolves which Plivo account sends the
// SMS / WhatsApp / Voice message for that brand's user.
//
// One way, two outcomes:
//
// 1. Brand-override path. The brand admin used the platform UI to wire
// a per-brand Plivo account. KMS holds the credentials at
// `kms.hanzo.ai → brand/<slug>/plivo/{auth-id, auth-token, sender-id, from-email}`.
// ResolvePlivoConfig returns those values.
//
// 2. Hanzo-default path. The brand has not wired its own Plivo.
// KMS still holds `kms.hanzo.ai → brand/hanzo/plivo/*` — that's
// Hanzo's fleet-shared account. ResolvePlivoConfig returns
// those.
//
// Fail-closed: if the default Hanzo record is missing or unreachable,
// the resolver returns an error and the caller surfaces 503. No hard-
// coded fallback (the rest of CLAUDE.md is loud about this — secrets
// only ever live in KMS).
//
// This file is the ONE place that knows about brand fallback. The
// existing Resolver (tenant.go) keeps owning per-tenant Provider rows
// for non-Plivo services (mail, sendgrid, twilio, …). Plivo is special
// because every Hanzo brand is a Plivo customer by default, so the
// row-driven discovery would be needless ceremony.
import (
"context"
"errors"
"fmt"
"strings"
"github.com/hanzoai/notify/internal/kmsbridge"
)
// DefaultBrand is the brand slug whose KMS credentials are used when the
// caller's brand has not configured an override. Lives in KMS at
// `brand/hanzo/plivo/*`.
const DefaultBrand = "hanzo"
// PlivoBrandKMSPathPrefix is the KMS path under which per-brand Plivo
// credentials live. Combined with brand slug:
//
// brand/<slug>/plivo/auth-id
// brand/<slug>/plivo/auth-token
// brand/<slug>/plivo/sender-id
// brand/<slug>/plivo/from-email
const PlivoBrandKMSPathPrefix = "brand"
// Plivo credential field names. Hard-coded; this is the KMS contract.
//
// from-email is included because the platform UI lets a brand set both
// SMS sender + email From in one form. notify reads the email value via
// the same resolver when channel=email and the brand uses Plivo's email
// product (or, more commonly, when the brand's email provider is wired
// alongside the SMS override).
const (
plivoFieldAuthID = "auth-id"
plivoFieldAuthToken = "auth-token"
plivoFieldSenderID = "sender-id"
plivoFieldFromEmail = "from-email"
)
// PlivoConfig is the resolved per-brand Plivo configuration. The
// SenderID doubles as the Plivo "Source" — either an E.164 number or a
// Powerpack UUID.
type PlivoConfig struct {
// Brand is the slug whose credentials produced this config. When a
// brand override exists, Brand == the requested brand. When the
// resolver fell back to the Hanzo default, Brand == "hanzo".
// Callers use this to log which Plivo account actually sent.
Brand string
// AuthID is the Plivo Auth ID (account-level credential).
AuthID string
// AuthToken is the Plivo Auth Token (account-level credential).
AuthToken string
// SenderID is the source number / shortcode / alphanumeric ID that
// will appear as the From on the SMS.
SenderID string
// FromEmail is the email address the brand sends from when notify
// channel=email is wired to the same per-brand config. Optional.
FromEmail string
// Override is true when this config came from the requested brand's
// own KMS entries (not the Hanzo default). Used by the platform
// UI's "current effective provider" indicator.
Override bool
}
// PlivoResolver resolves per-brand Plivo configuration via KMS. One
// instance per process is enough — the underlying KMSClient already
// memoizes secrets for 1m TTL.
type PlivoResolver struct {
kms *kmsbridge.Client
}
// NewPlivoResolver returns a PlivoResolver bound to the given KMS
// client. A nil KMS client is rejected at boot — fail-closed.
func NewPlivoResolver(kms *kmsbridge.Client) (*PlivoResolver, error) {
if kms == nil {
return nil, errors.New("plivo resolver: KMS client is required (multi-brand fallback cannot run without KMS)")
}
return &PlivoResolver{kms: kms}, nil
}
// ResolvePlivoConfig returns the Plivo credentials that should be used
// when sending for brand. The lookup order is:
//
// 1. brand/<requested>/plivo/* — the brand's own override.
// 2. brand/hanzo/plivo/* — the Hanzo default.
//
// On step 1 the resolver does NOT short-circuit on any non-EOF error: a
// KMS access error against the requested brand falls through to the
// default ONLY when the error indicates "secret not found". Any other
// error (auth fail, transport, 5xx) is surfaced — silently degrading to
// the Hanzo creds for someone else's transient KMS outage would
// risk sending the wrong brand's SMS during the outage window.
//
// On step 2 the resolver fail-closes: a missing or unreachable
// Hanzo default returns an error. notify callers surface 503.
//
// The empty string for `brand` is rejected as a programming error —
// the platform plugin always injects X-Org-Id before this fires.
func (r *PlivoResolver) ResolvePlivoConfig(ctx context.Context, brand string) (*PlivoConfig, error) {
brand = strings.TrimSpace(brand)
if brand == "" {
return nil, errors.New("plivo resolver: brand is required")
}
// Step 1 — try the brand's own override.
if brand != DefaultBrand {
cfg, err := r.fetchBrand(ctx, brand)
if err == nil {
cfg.Override = true
return cfg, nil
}
if !isMissingSecret(err) {
return nil, fmt.Errorf("plivo resolver: brand %q lookup failed: %w", brand, err)
}
// Fall through to the Hanzo default.
}
// Step 2 — Hanzo default. Errors here are fatal.
cfg, err := r.fetchBrand(ctx, DefaultBrand)
if err != nil {
return nil, fmt.Errorf("plivo resolver: default brand %q lookup failed (fail-closed, no hard-coded fallback): %w", DefaultBrand, err)
}
cfg.Override = brand == DefaultBrand
return cfg, nil
}
// fetchBrand reads the four KMS fields for a brand slug. auth-id +
// auth-token are required; sender-id is required for SMS so we error if
// blank; from-email is optional.
func (r *PlivoResolver) fetchBrand(_ context.Context, brand string) (*PlivoConfig, error) {
base := PlivoBrandKMSPathPrefix + "/" + brand + "/plivo"
authID, err := r.kms.GetSecret(brand, base+"/"+plivoFieldAuthID)
if err != nil {
return nil, err
}
authToken, err := r.kms.GetSecret(brand, base+"/"+plivoFieldAuthToken)
if err != nil {
return nil, err
}
senderID, err := r.kms.GetSecret(brand, base+"/"+plivoFieldSenderID)
if err != nil {
return nil, err
}
// from-email is optional. Missing or empty → from-email == "".
fromEmail, _ := r.kms.GetSecret(brand, base+"/"+plivoFieldFromEmail)
if strings.TrimSpace(authID) == "" || strings.TrimSpace(authToken) == "" {
return nil, fmt.Errorf("plivo resolver: brand %q has KMS rows but auth-id or auth-token is blank", brand)
}
if strings.TrimSpace(senderID) == "" {
return nil, fmt.Errorf("plivo resolver: brand %q has KMS rows but sender-id is blank (required for SMS)", brand)
}
return &PlivoConfig{
Brand: brand,
AuthID: authID,
AuthToken: authToken,
SenderID: senderID,
FromEmail: fromEmail,
}, nil
}
// isMissingSecret returns true when err looks like "the requested
// secret does not exist". The KMS facade does not export a typed error
// for this — we match on the message substring KMS returns ("not
// found"). This is the only place that pattern-matches; if KMS adds a
// typed error later, swap this implementation in one place.
func isMissingSecret(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "not found"),
strings.Contains(msg, "no such secret"),
strings.Contains(msg, "secret does not exist"),
strings.Contains(msg, "404"):
return true
}
return false
}