Files
zeekay d54b8709cb feat(hanzodns): provider-connector layer — native + Cloudflare over per-org KMS token
hanzodns becomes the one DNS control plane: authoritative zones served by its
own CoreDNS store AND third-party providers (Cloudflare) via the org's
KMS-sealed token. Both are first-class and coexist per-zone.

- Provider interface (provider.go) mirroring the platform dns-provider.ts:
  Type/ListZones/ZoneIDForName/ListRecords/Create/Update/DeleteRecord.
- Cloudflare connector (cloudflare.go) ported from cloudflare.ts — CF API v4
  CRUD, token only on the Authorization header, fail-closed, never logged.
- Per-org credential sourcing (kms.go): providerFor reads the sealed token from
  KMS by RELAYING the caller's own validated bearer to the KMS REST API
  (/v1/kms/orgs/{org}/secrets/integrations/cloudflare/api_token). hanzodns holds
  no standing cross-tenant credential — KMS enforces org isolation. org is the
  IAM  claim; auth-method-agnostic (apikey + oauth seal the same coord).
- Zone.provider dispatch (api.go): authoritative -> local store; cloudflare ->
  provider (source of truth) with a local-store mirror so CoreDNS serves it and
  listings stay consistent. OIDC wired into the auth chain so org + bearer reach
  the provider path.
- Durable authoritative zones (store_snapshot.go): opt-in file snapshot via
  HANZO_DNS_STATE_DIR — load on startup, atomic rewrite on every mutation, so a
  restart preserves native zones. Unset keeps the in-memory store.

Tests: CF connector, KMS bearer-relay (incl. foreign-org refusal), the full
create/list/delete dispatch for a Cloudflare zone, the native authoritative
path, and snapshot round-trip across a restart. go test -tags metrics green.
2026-07-18 10:05:09 -07:00

88 lines
2.9 KiB
Go

package hanzodns
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// kmsBaseURL is the Hanzo KMS REST origin. Defaults to the platform API and is
// overridable for non-prod / tests via HANZO_DNS_KMS_URL.
func kmsBaseURL() string {
if v := strings.TrimSpace(os.Getenv("HANZO_DNS_KMS_URL")); v != "" {
return strings.TrimRight(v, "/")
}
return "https://api.hanzo.ai"
}
var kmsHTTPClient = &http.Client{Timeout: 15 * time.Second}
// kmsSecret reads one org-scoped secret value from the Hanzo KMS REST API,
// relaying the CALLER's validated bearer (from ctx) as the Authorization so
// KMS's own org-scope guard authorizes the read. This is the least-privilege
// design: hanzodns holds no standing cross-tenant KMS credential, so a request
// can only reach the secrets of the org whose token it carried, and the KMS
// service — not this plugin — remains the single tenant-isolation boundary.
//
// subpath is the fixed secret coordinate under the org root, e.g.
// "integrations/cloudflare/api_token", producing
//
// GET {base}/v1/kms/orgs/{org}/secrets/integrations/cloudflare/api_token?env=default
//
// The secret value is returned to the caller but is NEVER logged, wrapped into
// an error, or placed anywhere other than the returned string.
func kmsSecret(ctx context.Context, org, subpath string) (string, error) {
bearer := BearerFromContext(ctx)
if bearer == "" {
return "", fmt.Errorf("no validated credential on request")
}
if org == "" {
return "", fmt.Errorf("no org on request")
}
// org is a single validated path segment; subpath is a fixed in-code
// coordinate (never client input), so only org is escaped.
endpoint := kmsBaseURL() + "/v1/kms/orgs/" + url.PathEscape(org) + "/secrets/" + subpath + "?env=default"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+bearer)
resp, err := kmsHTTPClient.Do(req)
if err != nil {
// Never wrap err: a transport error can echo the URL but never the
// header. Keep the message credential-free regardless.
return "", fmt.Errorf("kms request failed")
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusOK:
// fall through
case http.StatusNotFound:
return "", fmt.Errorf("cloudflare is not connected for this org")
case http.StatusUnauthorized, http.StatusForbidden:
return "", fmt.Errorf("not authorized to read this org's cloudflare token")
default:
return "", fmt.Errorf("kms returned status %d", resp.StatusCode)
}
var body struct {
Value string `json:"value"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
return "", fmt.Errorf("kms: malformed response")
}
if strings.TrimSpace(body.Value) == "" {
return "", fmt.Errorf("cloudflare token is empty")
}
return body.Value, nil
}