Files
gateway/auth_mustgate_test.go
zeekay 36b2646a8f Mount the route table instead of baking it
The image no longer copies a config: every deployment already mounts
its gateway.json at /etc/gateway, so one image serves every brand.
The Hanzo route table is deployment config and lives in universe
(infra/k8s/gateway/gateway.json). configs/example is a local starting
point, held to the same policy check as configs/lux.
2026-09-29 21:50:46 -07:00

532 lines
19 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
//go:build legacy
// +build legacy
// gin-driven: this suite drives the LEGACY Lura edge's transports
// (legacy_transports.go), which exist only under this tag. The policies it
// exercises are framework-free values shared with the zip edge, and
// transport_parity_test.go asserts the two edges answer alike.
package gateway
// Must-gate hardening regression tests (PR #34 follow-up).
//
// These lock in the edge-correctness fixes that pair with the gateway.json
// auth/validator surface: the /v1/commerce funding surface is never
// balance-gated, billing refuses to run half-configured, forged X-Project-Id
// never reaches a backend, and audience is enforced at the Go edge (ANY-of
// allowlist) — NOT in the config-declared auth/validator, whose go-jose v3 backend
// uses ALL-semantics and would 401 every single-aud user JWT.
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
"github.com/hanzoai/authz"
"github.com/hanzoai/gateway/v2/internal/lura/config"
"github.com/hanzoai/gateway/v2/internal/lura/logging"
"github.com/hanzoai/gateway/v2/internal/lura/proxy"
router "github.com/hanzoai/gateway/v2/internal/lura/router/gin"
)
// --- Finding #2: the /v1/commerce funding surface is NEVER balance-gated ---
func TestBillingPathMatch_ExcludesCommerce(t *testing.T) {
prefixes := []string{"/v1/commerce", "/v1/cloud", "/v1/mpc"}
// Funding surface and its sub-paths: never gated, even though listed in
// BILLING_PATHS. A 402/503 here would trap a zero-balance user out of the
// only page that lets them add funds.
for _, p := range []string{
"/v1/commerce",
"/v1/commerce/",
"/v1/commerce/billing/topup",
"/v1/commerce/checkout",
} {
if billingPathMatch(p, prefixes) {
t.Errorf("billingPathMatch(%q, withCommerce) = true; funding surface must never be balance-gated", p)
}
}
// Excluded even under empty BILLING_PATHS (enforce-everywhere) scope.
if billingPathMatch("/v1/commerce/topup", nil) {
t.Error("billingPathMatch(commerce, emptyPaths) = true; funding surface must never be balance-gated")
}
// Metered siblings remain gated (the exclusion is scoped to commerce, not
// a blanket disable).
for _, p := range []string{"/v1/cloud/deploy", "/v1/mpc/keys"} {
if !billingPathMatch(p, prefixes) {
t.Errorf("billingPathMatch(%q) = false; metered surface must be gated", p)
}
}
}
// --- Finding #3: billing refuses to run half-configured (fail fast/closed) ---
func TestAuthConfig_Validate(t *testing.T) {
configured := AuthConfig{
BillingEnabled: true,
BillingURL: "http://commerce.hanzo.svc.cluster.local:8001",
BillingToken: "svc-token",
}
if err := configured.Validate(); err != nil {
t.Errorf("fully-configured billing should validate, got %v", err)
}
noToken := configured
noToken.BillingToken = ""
if err := noToken.Validate(); err == nil {
t.Error("billing enabled without COMMERCE_SERVICE_TOKEN must fail validation")
}
noURL := configured
noURL.BillingURL = ""
if err := noURL.Validate(); err == nil {
t.Error("billing enabled without AUTH_BILLING_URL must fail validation")
}
if err := (AuthConfig{BillingEnabled: false}).Validate(); err != nil {
t.Errorf("billing disabled should always validate, got %v", err)
}
}
// When billing is enabled but its Commerce dependency is missing, the metered
// surface fails CLOSED (503) — never open — while the funding surface stays
// reachable so the user can still pay.
func TestAuthMiddleware_BillingMisconfiguredFailsClosed(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, func(c *AuthConfig) {
c.BillingEnabled = true
c.BillingURL = "http://commerce.invalid:8001"
c.BillingToken = "" // misconfigured: no service token
c.BillingPaths = []string{"/v1/cloud", "/v1/commerce"}
})
defer jwksServer.Close()
r.GET("/v1/cloud/usage", func(c *gin.Context) { c.Status(http.StatusOK) })
r.GET("/v1/commerce/balance", func(c *gin.Context) { c.Status(http.StatusOK) })
token := tj.signToken(t, validClaims("https://hanzo.id", "https://api.hanzo.ai"))
// Metered path -> 503 (fail closed; must never fail open to 200).
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/cloud/usage", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("misconfigured billing on metered path should 503 (fail closed), got %d", w.Code)
}
// Funding surface -> reachable (200), never balance-gated.
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/v1/commerce/balance", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("funding surface must stay reachable when billing misconfigured, got %d", w.Code)
}
}
// --- Finding #5: a forged X-Project-Id never reaches a backend ---
func TestValidAuth_StripsForgedXProjectId(t *testing.T) {
r, tj, jwksServer := setupMiddlewareWithJWKS(t, nil)
defer jwksServer.Close()
var gotProject string
r.GET("/v1/cloud/usage", func(c *gin.Context) {
gotProject = c.Request.Header.Get("X-Project-Id")
c.Status(http.StatusOK)
})
token := tj.signToken(t, validClaims("https://hanzo.id", "https://api.hanzo.ai"))
req := httptest.NewRequest(http.MethodGet, "/v1/cloud/usage", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Project-Id", "victim-project") // forged scope selector
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("valid JWT should get 200, got %d", w.Code)
}
if gotProject != "" {
t.Errorf("SECURITY: forged X-Project-Id reached backend = %q; the edge writes no project header, so it must be stripped", gotProject)
}
}
// --- Finding #4: audience is an ANY-of allowlist enforced at the Go edge ---
// TestJWTAuth_AudienceAllowlist_AnySemantics proves the edge accepts a token
// whose single aud is ANY listed value and rejects one outside the list. This
// is exactly why audience is enforced here and not in the config-declared
// auth/validator: the JOSE validator module (go-jose v3) requires the token to contain ALL
// configured audiences, so a multi-entry list there would reject every
// single-aud IAM user token.
func TestJWTAuth_AudienceAllowlist_AnySemantics(t *testing.T) {
allow := []string{"hanzo-app", "hanzo-console", "hanzo-chat", "https://api.hanzo.ai"}
r, tj, jwksServer := setupMiddlewareWithJWKS(t, func(c *AuthConfig) {
c.Audiences = allow
})
defer jwksServer.Close()
r.GET("/v1/test", func(c *gin.Context) { c.Status(http.StatusOK) })
for _, aud := range allow {
w := httptest.NewRecorder()
token := tj.signToken(t, validClaims("https://hanzo.id", aud))
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("token with single aud=%q (in allowlist) should pass, got %d", aud, w.Code)
}
}
w := httptest.NewRecorder()
token := tj.signToken(t, validClaims("https://hanzo.id", "evil-client"))
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
req.Host = "api.hanzo.ai"
req.Header.Set("Authorization", "Bearer "+token)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("token with aud outside the allowlist should 401, got %d", w.Code)
}
}
// --- One validator: the endpoint pipeline verifies nothing ---
//
// The shipping config used to carry 216 per-endpoint `auth/validator` blocks —
// a second JWT validation, with a second crypto library, against a second key
// source (an in-cluster CLEARTEXT jwk_url with disable_jwk_security), running
// after the edge had already validated the same token. The two disagreed in
// three ways that mattered: audience (the Go edge enforces an ANY-of allowlist;
// go-jose v3 requires ALL, which is why no `audience` could ever be written
// there), the org header (the edge resolves the EFFECTIVE org against the
// token's signed membership, the validator wrote the raw `owner` claim over the
// top of it), and API keys (the edge defers hk-/sk- to the backend that owns
// them, the validator 401'd them).
//
// Now there is one validator — the edge trust boundary — and an endpoint says
// whether it needs an identity with `auth/public`. These tests are what makes
// that checkable rather than asserted.
// repoConfigs are the configs this repo carries. The image carries none: the
// deployment mounts its own at /etc/gateway/gateway.json. Both are held to the
// same invariant the deployed one is.
var repoConfigs = []string{
"configs/example/gateway.json",
"configs/lux/gateway.json",
}
func readConfig(t *testing.T, path string) gatewayConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var cfg gatewayConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return cfg
}
type gatewayConfig struct {
Endpoints []struct {
Endpoint string `json:"endpoint"`
Method string `json:"method"`
ExtraConfig map[string]json.RawMessage `json:"extra_config"`
} `json:"endpoints"`
}
// TestShippingConfig_NoEndpointValidator is the structural pin: no shipping
// config may declare a JOSE validator or signer at an endpoint. The embedded
// schema refuses the key and the handler chain no longer installs the module,
// so one that appeared would be silently ignored — which is worse than either
// gating or not gating, because it would READ as a gate.
func TestShippingConfig_NoEndpointValidator(t *testing.T) {
for _, path := range repoConfigs {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
for _, banned := range []string{`"auth/validator"`, `"auth/signer"`} {
if bytes.Contains(data, []byte(banned)) {
t.Errorf("%s declares %s; this gateway runs ONE JWT validator (the edge trust boundary). "+
"An endpoint declares whether it needs an identity with \"auth/public\", it does not validate a second time.",
path, banned)
}
}
// The audience landmine that made the second validator unfixable rather
// than merely redundant: go-jose v3 validates audience with ALL-semantics,
// so any "audience" there would 401 every single-aud IAM user token.
// Audience is enforced at the Go edge (ANY-of allowlist) and nowhere else.
if bytes.Contains(data, []byte(`"audience"`)) {
t.Errorf(`%s contains an "audience" field; audience belongs to the Go edge allowlist`, path)
}
// X-Roles is a RETIRED identity header (authz.Retired): the edge deletes it
// on ingress and no edge writes it. The endpoint validator's propagate_claims
// was its last writer, so allowlisting it to a backend now describes a header
// that provably cannot be present.
if bytes.Contains(data, []byte(`"X-Roles"`)) {
t.Errorf("%s forwards X-Roles; it is retired — stripped on ingress and written by nothing", path)
}
}
}
// examplePublicEndpoints is the example's surface that answers WITHOUT a
// credential, written out so the diff that changes it is the diff that has to
// justify it: the health probes, the model catalog, and the inference route,
// which takes an hk-/sk- API key the backend resolves (the edge passes those
// through).
var examplePublicEndpoints = map[string]bool{
"GET /": true,
"GET /health": true,
"GET /v1/models": true,
"POST /v1/chat/completions": true,
}
// TestExampleConfig_PublicSurface is the behavioural pin on the example: every
// route states a policy, and the set reachable without a credential is exactly
// the set written above. A route that loses its gate shows up as an unexpected
// public entry; a route that gains one shows up as a missing entry.
func TestExampleConfig_PublicSurface(t *testing.T) {
cfg := readConfig(t, "configs/example/gateway.json")
seen := map[string]bool{}
for _, ep := range cfg.Endpoints {
key := ep.Method + " " + ep.Endpoint
raw, stated := ep.ExtraConfig["auth/public"]
if !stated {
t.Errorf("%s states no policy", key)
continue
}
var open bool
if err := json.Unmarshal(raw, &open); err != nil {
t.Errorf("%s: auth/public is not a boolean: %s", key, raw)
}
if open {
seen[key] = true
if !examplePublicEndpoints[key] {
t.Errorf("SECURITY: %s is declared public and is not in the public set", key)
}
}
}
for key := range examplePublicEndpoints {
if !seen[key] {
t.Errorf("%s is in the public set and is gated", key)
}
}
}
// TestLuxConfig pins the Lux edge's surface: every route states whether it needs
// an identity, and the routes that reach the node itself state that they do.
//
// The edge fronts blockchain RPC and carried no endpoint credential at all, so
// declaring the RPC routes public preserves their behaviour under a policy whose
// default is to require an identity. Three routes reach the node rather than the
// chain — POST /v1/admin (admin API), POST /v1/keystore (key management), GET
// /v1/metrics (node internals) — and each was open by omission. Writing a route
// down turns an omission into a decision, and none of the three is one anyone
// meant to make, so all three require an identity.
func TestLuxConfig(t *testing.T) {
cfg := readConfig(t, "configs/lux/gateway.json")
if len(cfg.Endpoints) == 0 {
t.Fatal("lux config declares no endpoints")
}
nodeControl := map[string]bool{
"POST /v1/admin": true,
"POST /v1/keystore": true,
"GET /v1/metrics": true,
}
seen := map[string]bool{}
for _, ep := range cfg.Endpoints {
name := ep.Method + " " + ep.Endpoint
seen[name] = true
raw, stated := ep.ExtraConfig["auth/public"]
if !stated {
t.Errorf("%s states no policy; every route on this edge says which it is", name)
continue
}
var open bool
json.Unmarshal(raw, &open)
switch {
case nodeControl[name] && open:
t.Errorf("%s reaches the node itself and is declared public", name)
case !nodeControl[name] && !open:
t.Errorf("%s is not declared public; the Lux edge gated nothing before and would now 401", name)
}
}
for name := range nodeControl {
if !seen[name] {
t.Errorf("%s is expected in the lux config and is absent", name)
}
}
}
// --- the requirement itself ---
// TestRequire is the unit pin on the endpoint half of the policy. It reads the
// identity the gate wrote and does nothing else: no key, no parse, no network.
func TestRequire(t *testing.T) {
for _, tc := range []struct {
name string
enabled bool
userID string
want int // 0 = allow
}{
{"verified identity passes", true, "hanzo/alice", 0},
{"no identity refuses", true, "", http.StatusUnauthorized},
{"auth disabled requires nothing", false, "", 0},
} {
t.Run(tc.name, func(t *testing.T) {
h := http.Header{}
if tc.userID != "" {
h.Set(authz.HeaderUser, tc.userID)
}
r := AuthConfig{Enabled: tc.enabled}.require(h)
switch {
case tc.want == 0 && r != nil:
t.Fatalf("refused with %d, want allow", r.Status)
case tc.want != 0 && r == nil:
t.Fatalf("allowed, want %d", tc.want)
case r != nil && r.Status != tc.want:
t.Fatalf("status = %d, want %d", r.Status, tc.want)
}
})
}
}
// TestRequireIdentity_EndToEnd drives the two halves over ONE request, which is
// the only way to show they cannot disagree: the gate verifies the token and
// writes the identity, the endpoint reads that identity. Nothing between them
// validates anything.
func TestRequireIdentity_EndToEnd(t *testing.T) {
gin.SetMode(gin.TestMode)
tj := newTestJWKS(t)
jwks := tj.serveJWKS(t)
defer jwks.Close()
authCfg := AuthConfig{
Enabled: true,
JWKSURL: jwks.URL,
Issuer: "https://hanzo.id",
Audiences: []string{"https://api.hanzo.ai"},
}
reached := map[string]bool{}
served := func(name string) router.HandlerFactory {
return func(*config.EndpointConfig, proxy.Proxy) gin.HandlerFunc {
return func(c *gin.Context) { reached[name] = true; c.Status(http.StatusOK) }
}
}
r := gin.New()
r.Use(NewAuthMiddleware(authCfg))
r.GET("/v1/kms/secrets", requireIdentity(served("gated"), logging.NoOp, authCfg)(
&config.EndpointConfig{Endpoint: "/v1/kms/secrets"}, nil))
r.POST("/v1/chat/completions", requireIdentity(served("public"), logging.NoOp, authCfg)(
&config.EndpointConfig{
Endpoint: "/v1/chat/completions",
ExtraConfig: config.ExtraConfig{authPublic: true},
}, nil))
do := func(method, path, credential string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := httptest.NewRequest(method, path, nil)
req.Host = "api.hanzo.ai"
if credential != "" {
req.Header.Set("Authorization", credential)
}
r.ServeHTTP(w, req)
return w
}
token := tj.signToken(t, validClaims("https://hanzo.id", "https://api.hanzo.ai"))
if w := do(http.MethodGet, "/v1/kms/secrets", ""); w.Code != http.StatusUnauthorized {
t.Errorf("gated endpoint, no credential: status = %d, want 401", w.Code)
}
if reached["gated"] {
t.Error("SECURITY: a tokenless request reached a gated backend")
}
if w := do(http.MethodGet, "/v1/kms/secrets", "Bearer "+token); w.Code != http.StatusOK {
t.Errorf("gated endpoint, valid JWT: status = %d, want 200 (body %s)", w.Code, w.Body)
}
// An API key carries no IAM identity by design — the backend that issued it
// resolves it. A route that needs an identity refuses it, exactly as the
// endpoint validator did.
if w := do(http.MethodGet, "/v1/kms/secrets", "Bearer hk-live-abcdef"); w.Code != http.StatusUnauthorized {
t.Errorf("gated endpoint, API key: status = %d, want 401", w.Code)
}
// The AI surface is where that API key is meant to go, and it is public for
// exactly that reason.
if w := do(http.MethodPost, "/v1/chat/completions", "Bearer hk-live-abcdef"); w.Code != http.StatusOK {
t.Errorf("public endpoint, API key: status = %d, want 200", w.Code)
}
if w := do(http.MethodPost, "/v1/chat/completions", ""); w.Code != http.StatusOK {
t.Errorf("public endpoint, no credential: status = %d, want 200", w.Code)
}
if !reached["public"] {
t.Error("public endpoint never reached its backend")
}
}
// TestRequireIdentity_ForgedIdentityCannotSatisfyIt is the pin that the
// requirement is not a header check a client can pass. The gate deletes every
// identity header on ingress, so a forged X-User-Id is gone before the endpoint
// looks for one — which is why reading the header is safe here and would not be
// anywhere else.
func TestRequireIdentity_ForgedIdentityCannotSatisfyIt(t *testing.T) {
gin.SetMode(gin.TestMode)
tj := newTestJWKS(t)
jwks := tj.serveJWKS(t)
defer jwks.Close()
authCfg := AuthConfig{
Enabled: true,
JWKSURL: jwks.URL,
Issuer: "https://hanzo.id",
Audiences: []string{"https://api.hanzo.ai"},
}
var reached bool
hf := func(*config.EndpointConfig, proxy.Proxy) gin.HandlerFunc {
return func(c *gin.Context) { reached = true; c.Status(http.StatusOK) }
}
r := gin.New()
r.Use(NewAuthMiddleware(authCfg))
r.GET("/v1/kms/secrets", requireIdentity(hf, logging.NoOp, authCfg)(
&config.EndpointConfig{Endpoint: "/v1/kms/secrets"}, nil))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/kms/secrets", nil)
req.Host = "api.hanzo.ai"
req.Header.Set(authz.HeaderUser, "hanzo/attacker")
req.Header.Set(authz.HeaderOrg, "victim-org")
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("forged identity: status = %d, want 401", w.Code)
}
if reached {
t.Error("SECURITY: a forged X-User-Id satisfied the endpoint credential requirement")
}
}