Files
zeekay 16e9b4d525 fix(hanzodns): org-scope the store + fail-closed auth (red HIGH/MED/LOW)
Red found the authoritative store had no tenant isolation: only the CF-token
read was org-scoped, so on the shared multi-tenant instance any authenticated
org could list/read/mutate/delete another org's zones and records (DNS hijack /
outage). Fixes:

HIGH — org-scope the store structurally. Zone carries an Org (the validated
`owner` claim, stamped server-side); every Store method takes org and funnels
through zoneForOrg(org,name), so a caller can never reach another org's zone
even if a handler forgets the check. List filters to the caller's org;
get/mutate/delete of a foreign or absent zone is 'not found' (404, no existence
confirmation); zone names are globally unique on the shared nameservers so a
name taken by another org is a conflict. /v1/dns/sync derives the owner from the
ctx principal (never the body — the dead SyncZoneRequest.OrgID is removed) and
refuses to overwrite another org's zone.

MED — auth fails closed. authChain denies (503) when neither OIDC nor a static
key is configured; the anonymous path is reachable only behind an explicit
HANZO_DNS_DEV_INSECURE=1 opt-in (authMiddleware no longer allow-alls on an empty
key). OIDC audience is a comma-separated allowlist (string-or-array aud), and a
token with no exp is rejected.

LOW — Cloudflare updates now PATCH only the caller's changed fields (a stale
local mirror can't revert an out-of-band upstream change); a create/update
mirror failure is logged, not swallowed.

Tests: TestCrossTenantIsolation (org B gets 404 on list/get/create/update/
delete of org A's zone+records, org A's data intact), TestSyncIsOrgScoped,
TestAuthChainFailsClosedByDefault, TestDevInsecureAllows. go test -tags metrics
-race green; go build ./... green.
2026-07-18 10:58:01 -07:00

105 lines
3.7 KiB
Go

package hanzodns
import (
"context"
"fmt"
)
// A zone is served by exactly one backend, named by its provider. Authoritative
// zones live in this CoreDNS store and are answered locally; provider zones are
// delegated to a third-party control plane (Cloudflare today) via the org's
// KMS-sealed credential. Both are first-class: an org may run some zones on
// Hanzo's own nameservers and point others at a connected Cloudflare account.
const (
ProviderAuthoritative = "authoritative"
ProviderCloudflare = "cloudflare"
)
// validProviders is the set of provider names a zone may declare.
var validProviders = map[string]bool{
ProviderAuthoritative: true,
ProviderCloudflare: true,
}
// ProviderZone is a zone as reported by an upstream provider.
type ProviderZone struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
NameServers []string `json:"name_servers"`
}
// ProviderRecord is a record as reported by an upstream provider.
type ProviderRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL uint32 `json:"ttl"`
Proxied bool `json:"proxied"`
Priority uint16 `json:"priority,omitempty"`
}
// RecordInput is the payload for creating or updating a provider record.
type RecordInput struct {
Name string
Type string
Content string
TTL uint32
Proxied bool
Priority uint16
}
// Provider is the connector contract for a third-party DNS control plane. It
// mirrors the platform's dns-provider.ts interface: an implementation holds a
// per-org credential and maps the canonical operations onto the provider API.
// Records are addressed by the provider's own zone/record ids; ZoneIDForName
// bridges the Hanzo API (keyed by zone NAME) to that id space.
type Provider interface {
Type() string
ListZones(ctx context.Context) ([]ProviderZone, error)
ZoneIDForName(ctx context.Context, name string) (string, error)
ListRecords(ctx context.Context, zoneID string) ([]ProviderRecord, error)
CreateRecord(ctx context.Context, zoneID string, in RecordInput) (ProviderRecord, error)
// UpdateRecord applies a PARTIAL update: only the patch's set fields change,
// so an out-of-band change to the record's other fields upstream is preserved.
UpdateRecord(ctx context.Context, zoneID, recordID string, patch RecordPatch) (ProviderRecord, error)
DeleteRecord(ctx context.Context, zoneID, recordID string) error
}
// providerFor builds the Provider for (org, kind).
//
// For an authoritative zone it returns (nil, nil): the local store is the
// backend, so there is nothing to construct. For "cloudflare" it reads the
// org's scoped API token from KMS using the caller's own bearer (relayed from
// ctx by kmsSecret) and returns a Cloudflare connector bound to that token. The
// token is auth-method-agnostic: whether the org connected Cloudflare via an
// API key or OAuth, both seal the token to the same KMS coordinate, so this
// path just reads it.
func providerFor(ctx context.Context, org, kind string) (Provider, error) {
switch kind {
case "", ProviderAuthoritative:
return nil, nil
case ProviderCloudflare:
token, err := kmsSecret(ctx, org, "integrations/cloudflare/api_token")
if err != nil {
return nil, err
}
return newCloudflareProvider(token), nil
default:
return nil, fmt.Errorf("unknown provider %q", kind)
}
}
// normProvider validates and normalizes a requested provider name, defaulting
// to authoritative when unset.
func normProvider(p string) (string, error) {
if p == "" {
return ProviderAuthoritative, nil
}
if !validProviders[p] {
return "", fmt.Errorf("unknown provider %q", p)
}
return p, nil
}