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.
hanzodns
Name
hanzodns - REST API for dynamic DNS zone and record management.
Description
The hanzodns plugin exposes a REST API that allows creating, reading, updating, and deleting DNS zones and records at runtime. Records added through the API are immediately available for DNS resolution by CoreDNS.
The plugin runs a side-car HTTP server (default :8443) alongside CoreDNS and stores records in-memory. The store is designed to be swappable for a persistent backend (e.g., PostgreSQL) in the future.
Syntax
hanzodns [ADDR]
- ADDR - the address to listen on for the HTTP API (default
:8443).
Authentication
The API has one auth boundary, selected by configuration, and fails closed:
- OIDC (production, multi-tenant). Set
HANZO_DNS_OIDC_ISSUER(e.g.https://hanzo.id) to validate the caller's IAM JWT on every endpoint (except/v1/dns/health). The middleware requires a signed, unexpired token, extracts the org (theownerclaim), and keeps the caller's bearer for the provider path. SetHANZO_DNS_OIDC_AUDIENCEto a comma-separated allowlist to also enforce the audience (reject tokens minted for other services) — required in production. - Static key (standalone, single-tenant). With no issuer,
HANZO_DNS_API_KEYrequires a shared Bearer token. - Denied. With neither configured the API returns 503 — the anonymous "allow all" is reachable ONLY behind an explicit
HANZO_DNS_DEV_INSECURE=1opt-in, never by default.
Authorization: Bearer <token>
Tenant isolation
Every zone is owned by the org that created it (the validated owner claim), and org is the single isolation key enforced inside the store, not just at the edge: a caller only ever lists, reads, or mutates its own org's zones and records. A request for another org's zone or record is 404 (existence is never confirmed cross-tenant), and /v1/dns/sync can only create or replace the caller's own zones. Multi-tenant deployments therefore require OIDC (the static-key mode is single-tenant).
Providers
A zone is served by one backend, named by its provider:
authoritative(default) — records live in this CoreDNS store and are answered locally. This is Hanzo's own nameservers; fully first-class.cloudflare— records are managed in the org's connected Cloudflare account. The plugin reads the org's scoped API token from Hanzo KMS (path/orgs/{org}/integrations/cloudflare/api_token) by relaying the caller's own validated bearer to the KMS REST API ({HANZO_DNS_KMS_URL}/v1/kms/orgs/{org}/secrets/..., defaulthttps://api.hanzo.ai). The plugin holds no standing cross-tenant credential — KMS enforces org isolation, and the token is never logged. Provider-backed records are also mirrored into the local store so CoreDNS can serve the zone and listings stay consistent.
Create a Cloudflare-backed zone by passing provider on zone creation:
curl -X POST https://dns.hanzo.ai/v1/dns/zones \
-H "Authorization: Bearer $ORG_JWT" \
-d '{"zone": "example.com", "provider": "cloudflare"}'
Durability
Authoritative zones are held in memory. Set HANZO_DNS_STATE_DIR to a writable directory to make them durable: the store loads its last snapshot on startup and rewrites it (atomically) after every mutation, so a restart preserves native zones. Unset keeps the pure in-memory store. Cross-replica consistency and reschedule durability are provided by the platform re-pushing authoritative zones via POST /v1/dns/sync (the CRD 150-dns-records reconcile).
API Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /v1/dns/health |
Health check (no auth) |
| GET | /v1/dns/zones |
List all zones |
| POST | /v1/dns/zones |
Create a zone |
| GET | /v1/dns/zones/{zone} |
Get zone details |
| DELETE | /v1/dns/zones/{zone} |
Delete a zone |
| GET | /v1/dns/zones/{zone}/records |
List records in a zone |
| POST | /v1/dns/zones/{zone}/records |
Create a record |
| GET | /v1/dns/zones/{zone}/records/{id} |
Get a record |
| PATCH | /v1/dns/zones/{zone}/records/{id} |
Update a record |
| DELETE | /v1/dns/zones/{zone}/records/{id} |
Delete a record |
Record Types
A, AAAA, CNAME, MX, TXT, SRV, NS, SOA, CAA
Examples
Enable the API on the default port:
. {
hanzodns
forward . 8.8.8.8
}
Listen on a custom port:
. {
hanzodns :9443
forward . 8.8.8.8
}
Create a zone and record via curl:
# Create zone
curl -X POST http://localhost:8443/v1/dns/zones \
-H "Authorization: Bearer $HANZO_DNS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"zone": "example.com"}'
# Add A record
curl -X POST http://localhost:8443/v1/dns/zones/example.com/records \
-H "Authorization: Bearer $HANZO_DNS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "www", "type": "A", "content": "1.2.3.4", "ttl": 300}'
# Query it
dig @localhost www.example.com A