mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
Since the HIP-0110 ZAP-relay rearchitecture, cmd/gateway/main's
dialBackends eagerly dialed cloud:9090 / base:9091 at startup and
os.Exit(1)'d on any failure. When those backends are not deployed (the
steady state in prod), every gateway image past v2.14.1 crashlooped —
freezing the gateway on v2.14.1 and blocking the merged CORS fix and the
billing gate. HTTP serving (what api.hanzo.ai actually needs) worked fine.
Decomplect boot from backend reachability:
- startNode (was dialBackends) only starts the ZAP node. The single
fatal case left is a genuine local error — failure to bind the
ephemeral port. No dialing at startup.
- peerResolver (new, peer_resolver.go) lazily resolves a backend
address to its handshake-learned peerID, dialing on first use with
single-flight, caching once connected. ConnectDirectID is idempotent,
so a healthy backend is dialed exactly once and ZAP is unchanged.
- NewLazyPicker best-effort warms both backends (failure -> WARN, not
fatal) and returns the forward.PeerPicker. A dead backend makes the
picker return "" -> node.Call yields a per-request "peer not found"
error that forward.Relay surfaces as an HTTP error, never a crash.
The dial is retried on the next matching request (heals with no
restart once the backend appears).
- isBasePath is the single definition of the /v1/base/* split, shared
by the static pickPeer (tests) and the lazy picker (prod).
- RelayDeps.Pick lets main pass the lazy picker; nil falls back to the
static pickPeer for the pre-existing e2e test.
Tests (peer_resolver_test.go): boot succeeds with backends absent (no
hang/crash), healthz still serves 200, picker returns "" for dead
backends, ZAP routes+caches correctly when backends exist, and recovers
when a backend comes up. All pass under -race.
184 lines
7.2 KiB
Go
184 lines
7.2 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
// gate.go is the gateway relay's authn policy, run on every inbound
|
|
// Forward ENVELOPE per HIP-0110. It is the edge trust boundary: the gate
|
|
// validates the IAM JWT carried in the Forward's headers and stamps the
|
|
// resolved identity (TenantID/UserID/IsAdmin/Permissions) onto the
|
|
// envelope so the backend trusts the edge. forward.Relay then re-emits
|
|
// the Forward with that identity and ships it to the chosen backend.
|
|
//
|
|
// One auth implementation: the gate reuses package iamauth (the same
|
|
// JWKS cache + JWT validation + token extraction shared with cmd/ingress
|
|
// and the gin/KrakenD middleware) and gateway's own permission bit-field
|
|
// math (computePermissionsBitField / permissionBits). No second copy.
|
|
//
|
|
// Billing is NOT enforced here. The gateway is authn + identity injection
|
|
// only; cloud-api's own prepaid balance gate bills the bridged request.
|
|
// This split is deliberate (HIP-0110): the relay must not read f.Body and
|
|
// must not block on a per-request Commerce round-trip — it stamps identity
|
|
// and forwards. The gate therefore touches f.Path and f.Headers only.
|
|
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/luxfi/zap/forward"
|
|
|
|
"github.com/hanzoai/gateway/iamauth"
|
|
)
|
|
|
|
// newGate builds the forward.Gate from an AuthConfig. The JWKS cache is
|
|
// constructed once and captured by the returned closure, so repeated
|
|
// Forwards reuse cached signing keys (TTL-refreshed, stale-on-error) just
|
|
// like the gin middleware.
|
|
//
|
|
// Policy (identical to NewAuthMiddleware, the established one way):
|
|
// - public path -> allow, no identity (health, .well-known, favicon…)
|
|
// - no token -> allow with no identity, UNLESS cfg.RequireAuth, in
|
|
// which case deny 401 (the edge is the authn boundary)
|
|
// - API key -> allow, no JWT identity (validated downstream by the
|
|
// backend: cloud-api, commerce, base…)
|
|
// - invalid JWT -> deny 401
|
|
// - valid JWT -> inject identity, allow
|
|
//
|
|
// On allow the gate mutates f's identity fields in place and returns
|
|
// (nil, nil). On deny it returns a non-nil *forward.Response carrying a
|
|
// clean HTTP status + JSON error so the client sees a real response. It
|
|
// never returns a non-nil error for ordinary policy rejection (that would
|
|
// fail the caller's Call instead of yielding an HTTP status).
|
|
func newGate(cfg AuthConfig) forward.Gate {
|
|
// Auth disabled (AUTH_ENABLED=false): forward everything with no edge
|
|
// identity, same pass-through the gin middleware does in dev/test. No
|
|
// JWKS cache is constructed.
|
|
if !cfg.Enabled {
|
|
return func(context.Context, *forward.Forward) (*forward.Response, error) {
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
cache := newJWKSCache(cfg.JWKSURL, jwksTTL)
|
|
|
|
publicPaths := cfg.PublicPaths
|
|
|
|
return func(_ context.Context, f *forward.Forward) (*forward.Response, error) {
|
|
// Trust boundary: clear any inbound envelope identity FIRST, before
|
|
// any allow path. forward.Forwarder lifts a client's X-Org-Id /
|
|
// X-User-Id headers into f.TenantID / f.UserID; only a validated JWT
|
|
// may re-assert them. This is the relay's counterpart to the gin
|
|
// middleware's StripIdentityHeaders — a forged identity can never
|
|
// survive on the no-token, API-key, public, or invalid-token paths.
|
|
f.TenantID = ""
|
|
f.UserID = ""
|
|
f.IsAdmin = false
|
|
f.Permissions = 0
|
|
|
|
// Public paths bypass auth entirely — same prefix match the gin
|
|
// middleware uses. f.Path carries the raw query inline; prefix
|
|
// match is on the leading path which is unaffected by a query.
|
|
for _, pp := range publicPaths {
|
|
if pp != "" && strings.HasPrefix(f.Path, pp) {
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
token := tokenFromForward(f)
|
|
if token == "" {
|
|
if cfg.RequireAuth {
|
|
return denyJSON(http.StatusUnauthorized, "unauthorized",
|
|
"Authentication required"), nil
|
|
}
|
|
// No credential: forward with no edge identity. The backend
|
|
// applies its own policy (and cloud-api its balance gate).
|
|
return nil, nil
|
|
}
|
|
|
|
// Opaque API keys (hk-/sk-/fw_/hz_/pk-) are validated by the
|
|
// backend service directly, not as a JWT here. Pass them through
|
|
// with no edge identity, exactly like the gin middleware.
|
|
if isAPIKey(token) {
|
|
return nil, nil
|
|
}
|
|
|
|
claims, err := validateToken(token, cache, cfg.Issuer, cfg.Audience)
|
|
if err != nil {
|
|
return denyJSON(http.StatusUnauthorized, "unauthorized",
|
|
"Invalid token"), nil
|
|
}
|
|
|
|
// Inject the validated identity onto the envelope in place. These
|
|
// are the only fields the gate mutates; it never touches f.Body.
|
|
f.TenantID = claims.Owner
|
|
f.UserID = claims.UserID()
|
|
f.IsAdmin = claims.IsAdmin
|
|
|
|
// X-User-Permissions bit-field: isAdmin implies Admin|Live (same as
|
|
// the gin middleware), the explicit "permissions" claim is OR'd on
|
|
// top. Absent/unmapped -> 0, which the backend reads as no rights.
|
|
var extraBits int64
|
|
if claims.IsAdmin {
|
|
extraBits = permissionBits["admin"] | permissionBits["live"]
|
|
}
|
|
bits, _ := computePermissionsBitField(claims.Permissions, extraBits)
|
|
f.Permissions = bits
|
|
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
// tokenFromForward extracts the IAM credential from the Forward's headers
|
|
// using the canonical iamauth extraction (Bearer / HTTP Basic password /
|
|
// session cookie incl. iam_access_token). It reconstructs a throwaway
|
|
// *http.Request from f.Headers — the JSON map[string][]string canonical
|
|
// header set — and delegates to iamauth.ExtractToken so there is exactly
|
|
// one extraction implementation. It reads f.Headers ONLY; never f.Body.
|
|
func tokenFromForward(f *forward.Forward) string {
|
|
if len(f.Headers) == 0 {
|
|
return ""
|
|
}
|
|
var hdrs map[string][]string
|
|
if err := json.Unmarshal(f.Headers, &hdrs); err != nil {
|
|
return ""
|
|
}
|
|
return iamauth.ExtractToken(&http.Request{Header: http.Header(hdrs)})
|
|
}
|
|
|
|
// denyJSON builds a deny *forward.Response with a JSON error body and the
|
|
// JSON content-type header, so the short-circuited client sees a clean
|
|
// HTTP error instead of a bare status.
|
|
func denyJSON(status int, errCode, message string) *forward.Response {
|
|
body, _ := json.Marshal(map[string]string{"error": errCode, "message": message})
|
|
hdrs, _ := json.Marshal(map[string][]string{
|
|
"Content-Type": {"application/json"},
|
|
})
|
|
return &forward.Response{Status: status, Body: body, Headers: hdrs}
|
|
}
|
|
|
|
// isBasePath reports whether a request path routes to the base backend.
|
|
// This is the single definition of the base/cloud split — both the static
|
|
// pickPeer (tests) and the lazy peerResolver.picker (production) consult it,
|
|
// so the routing rule lives in exactly one place.
|
|
func isBasePath(path string) bool {
|
|
return strings.HasPrefix(path, "/v1/base/")
|
|
}
|
|
|
|
// pickPeer routes a Forward by path: /v1/base/* -> the base peer NodeID,
|
|
// everything else -> the cloud peer NodeID. The NodeIDs are the
|
|
// handshake-learned ids of the already-connected peers (captured at dial
|
|
// time via ConnectDirectID), so forward.Relay's node.Call resolves them
|
|
// against the existing connections.
|
|
//
|
|
// This static form is used where the peerIDs are known up front (tests).
|
|
// Production uses peerResolver.picker, which dials lazily and shares the
|
|
// same isBasePath routing rule.
|
|
func pickPeer(basePeerID, cloudPeerID string) forward.PeerPicker {
|
|
return func(path string) string {
|
|
if isBasePath(path) {
|
|
return basePeerID
|
|
}
|
|
return cloudPeerID
|
|
}
|
|
}
|