Files
gateway/gate.go
zeekay cbc3c31192 edge: one validator, one ingress
The endpoint pipeline ran a SECOND JWT validation to answer a question the edge
had already answered one middleware earlier — a second crypto library, a second
JWKS URL, and ALL-of audience semantics against the edge's ANY-of, which 401s
every user JWT IAM issues. Requiring a credential and verifying one are
different jobs; only the second needs a key, and only one thing should do it.
Routes now declare `auth/public` and the edge is the one validator.

The ingress binary, its config and its manifests leave this repo. They were a
second copy of a service that has its own, and the two had drifted.
2026-08-21 07:01:07 -07:00

194 lines
7.9 KiB
Go

// Copyright © 2026 Hanzo AI. Apache-2.0 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 hanzoai/authz/edge (the same
// JWKS cache + JWT validation + token extraction shared with hanzoai/ingress
// and the gin/Lura 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'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/authz"
"github.com/hanzoai/authz/edge"
)
// 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, 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
}
}
validator := newValidator(cfg)
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.
//
// TenantID IS X-Org-Id (forward.Forward), so the value being cleared is
// the org the client SELECTED. Keep it as an INTENT — it is re-asserted
// below only through EffectiveOrg, which admits it only if the validated
// token's signed membership set does. Exactly the capture the gin
// middleware's StripIdentityHeaders performs on the HTTP wire.
selectedOrg := f.TenantID
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 its balance gate).
return nil, nil
}
// Opaque API keys (sk-/pk-/fw_/hz_, and retired hk-) 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 authz.IsAPIKey(token) {
return nil, nil
}
claims, err := validator.VerifyRaw(token)
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.
//
// TenantID is the EFFECTIVE org — the client's selection when the token's
// membership set admits it, else the home org. The relay does NOT bill (see
// RegisterRelay: cloud bills the bridged request), so there is no ledger
// question to answer here; cloud re-validates the same Bearer and applies
// its own payer policy to the org this puts on the wire.
f.TenantID, _ = claims.EffectiveOrg(selectedOrg)
f.UserID = claims.UserID()
// PLATFORM authority, from the platform predicate. forward re-emits this as
// X-User-IsAdmin (forward/serve.go), so setting it from claims.IsAdmin — IAM's
// ORG-role bit — made every org owner a platform admin on the relay path too.
f.IsAdmin = claims.PlatformSudo()
// The money bit-field, from the ONE rule both transports read (identity.go).
f.Permissions = moneyBits(claims)
return nil, nil
}
}
// tokenFromForward extracts the IAM credential from the Forward's headers
// using the canonical edge.Token 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 edge.Token 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 edge.Token(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
}
}