mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
The last three first-party gin files were transports wrapped around decisions, and the decisions could not be reached without the framework. That braiding had already cost this repo a silent hole once (the fiber adaptor restoring headers the strip had deleted); it was still costing two more, quietly. widget_security.go and the CORS half of routes.go had NO zip transport at all. Not a weaker one — none. So on the HIP-0106 mount, the boundary the unified cloud binary runs, an hz_ widget key — a public credential anyone can read out of a page's source — arrived with no origin allowlist and no rate limit against the model budget, and a browser preflight got whatever the router does with an unrouted OPTIONS. Both gates existed, both were tested, and neither ran, because each was written inside a gin closure the zip edge could not call. That is the same defect class as the deleted gin bridge, one level up: the decision was in a place instead of being a value. Now each gate is a value and each edge is a few lines over it — widgetGate.admit in widget_security.go and corsPolicy.admit in cors.go, alongside the authGate.admit that already was one. Mount installs all three natively, in the order legacy_engine.go runs them. Every gin transport moved to legacy_transports.go behind the tag: one file, one reason to exist, deleted whole with legacy_engine.go at Phase C. What that buys, measured on the binary rather than on a test: go list -deps ./cmd/gateway | grep gin-gonic 6 packages -> 0 package count 2161 -> 2112 And what it does NOT buy, stated because the opposite is the easy thing to claim: gin CANNOT leave go.mod, and gin is still in the SHIPPED binary. The Makefile sets BUILD_TAGS ?= legacy, the Dockerfile runs `make build`, and all five live workloads run `run -c gateway.json` — so ghcr.io/hanzoai/gateway is the legacy build, and its gin comes from the vendored KrakenD/Lura tree (internal/lura, internal/plugin), which is not ours to rewrite. `go list -deps -tags legacy` still reports gin, unchanged at 2680 packages. First-party gin is gone; the vendored legacy tree keeps it until Phase C. The shipped edge is proven unmoved, not asserted to be: both binaries booted against configs/hanzo/gateway.json and probed on fourteen requests covering every gate. Route table 19 endpoints before and 19 after, and every response — status line, CORS headers, security posture, BODY — diffs clean against origin/main. Tests: transport_parity_test.go drives one request through BOTH edges over 25 cases (auth classes, widget origin+rate limit, CORS preflight) and fails on disagreement rather than on either edge alone; it was mutation-checked, and a broken zip transport reports TRANSPORTS DISAGREE on 6 of them. TestMount_GatesAreOnTheChainMountInstalls drives the app gateway.Mount BUILT, because a gate that passes its own test while the mount never installs it is exactly what was wrong here. The gin-driven suites are `legacy`-tagged — that build is the only one where a gin transport exists, and it is the one that ships, so nothing stopped running: 215 tests pass in the legacy pass and the untagged pass is green too, both with -race. Shared JWKS fixtures moved to an untagged fixtures_test.go so the two halves cannot grow two signing identities. The routing table is the one gate with no zip twin, and routes.go says why rather than deferring: its compiled proxies are net/http, so reaching them from fasthttp would drop WebSocket upgrade and streaming, and the api-host passthrough rests on cloud being another process — false inside the unified binary, where the same rule would proxy it to itself. Co-authored-by: Hanzo Dev <[email protected]>
273 lines
11 KiB
Go
273 lines
11 KiB
Go
package gateway
|
|
|
|
// The org a request ACTS IN and the org that PAYS — the edge half of the org
|
|
// switcher, end to end on both transports that carry client traffic: the native
|
|
// zip HTTP edge (gateway.Mount) and the ZAP relay gate.
|
|
//
|
|
// A person belongs to several orgs, picks one, and that org must be the one whose
|
|
// data they see AND the one whose ledger is charged. The selection rides in
|
|
// X-Org-Id, which is also the header the edge WRITES, so the whole contract is: the
|
|
// client's copy is deleted at ingress and can only come back if the token's SIGNED
|
|
// `orgs` membership set admits it.
|
|
//
|
|
// These are tenant-isolation and money tests. They must keep passing before any
|
|
// merge to main.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/authz"
|
|
luxlog "github.com/luxfi/log"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// commerceStub stands in for commerce's GET /v1/billing/balance. It records the
|
|
// `user` the edge asked about — the org/sub composite whose org half is the LEDGER
|
|
// — and always answers with funds, so a test failure means the wrong ledger was
|
|
// named, never that the request was merely denied.
|
|
func commerceStub(t *testing.T, billed *string) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
*billed = r.URL.Query().Get("user")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(billingResponse{User: *billed, Currency: "usd", Available: 100000})
|
|
}))
|
|
}
|
|
|
|
// orgRequest drives one request through the middleware with the given claims and
|
|
// (optional) client org selection, returning the org the backend saw, the home org
|
|
// it saw, and the user commerce was asked to bill.
|
|
func orgRequest(t *testing.T, claims authz.Claims, selected string) (org, home, billed string) {
|
|
t.Helper()
|
|
commerce := commerceStub(t, &billed)
|
|
defer commerce.Close()
|
|
|
|
tj := newTestJWKS(t)
|
|
jwksServer := tj.serveJWKS(t)
|
|
defer jwksServer.Close()
|
|
|
|
cfg := edgeAuthConfig(jwksServer.URL)
|
|
cfg.BillingEnabled = true
|
|
cfg.BillingURL = commerce.URL
|
|
cfg.BillingToken = "s2s"
|
|
|
|
app := zip.New(zip.Config{Logger: luxlog.New("test"), AppName: "gateway"})
|
|
app.Use(zipAuth(cfg))
|
|
app.Get("/v1/cloud/thing", func(c *zip.Ctx) error {
|
|
org = c.Header("X-Org-Id")
|
|
home = c.Header("X-User-Owner")
|
|
return c.String(http.StatusOK, "")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/cloud/thing", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
req.Header.Set("Authorization", "Bearer "+tj.signToken(t, claims))
|
|
if selected != "" {
|
|
req.Header.Set("X-Org-Id", selected)
|
|
}
|
|
|
|
resp, err := app.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("zip: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("request denied with %d: %s", resp.StatusCode, body)
|
|
}
|
|
return org, home, billed
|
|
}
|
|
|
|
// memberClaims is a person whose home org is acme and who also belongs to
|
|
// beta-team — the ordinary switcher subject, with IAM's signed membership set.
|
|
func memberClaims() authz.Claims {
|
|
c := validClaims("https://hanzo.id", "https://api.hanzo.ai")
|
|
c.Owner = "acme"
|
|
c.RegisteredClaims.Subject = "alice"
|
|
c.Orgs = []authz.Membership{{Org: "acme", Role: "admin"}, {Org: "beta-team", Role: "member"}}
|
|
return c
|
|
}
|
|
|
|
// TestOrg_NoSelectionIsUnchanged is the no-op proof for every caller that does not
|
|
// switch: with no inbound X-Org-Id the edge writes exactly what it wrote before the
|
|
// switcher existed — X-Org-Id == X-User-Owner == the JWT owner — and bills the same
|
|
// org/sub key. Nothing about a non-switching request moves.
|
|
func TestOrg_NoSelectionIsUnchanged(t *testing.T) {
|
|
org, home, billed := orgRequest(t, memberClaims(), "")
|
|
if org != "acme" || home != "acme" {
|
|
t.Errorf("X-Org-Id/X-User-Owner = %q/%q, want acme/acme", org, home)
|
|
}
|
|
if billed != "acme/alice" {
|
|
t.Errorf("billed %q, want acme/alice", billed)
|
|
}
|
|
}
|
|
|
|
// TestOrg_LegacyTokenResolvesNoOrg proves a token minted before the `orgs` claim
|
|
// existed resolves NO org at all — not its selection, and not a home org either.
|
|
//
|
|
// It used to fall back to the `owner` claim, and that claim is the org of the
|
|
// APPLICATION the token was minted through, so falling back made a person's tenant
|
|
// follow whichever app they signed in through. Empty means empty: the request
|
|
// reaches backends with no X-Org-Id and every org gate refuses it.
|
|
//
|
|
// The cost is bounded to one token TTL after rollout, and the alternative is
|
|
// trusting an app-selected value as the user's tenant.
|
|
func TestOrg_LegacyTokenResolvesNoOrg(t *testing.T) {
|
|
legacy := memberClaims()
|
|
legacy.Orgs = nil
|
|
|
|
org, home, billed := orgRequest(t, legacy, "beta-team")
|
|
if org != "" || home != "" {
|
|
t.Errorf("X-Org-Id/X-User-Owner = %q/%q, want empty (no membership set ⟹ no org)", org, home)
|
|
}
|
|
if billed != "alice" {
|
|
t.Errorf("billed %q, want the bare subject — no org to key it on", billed)
|
|
}
|
|
}
|
|
|
|
// TestOrg_MemberSelectionPays is the whole point: a member selects a team org, the
|
|
// edge verifies it against the signed set, and BOTH the data scope and the ledger
|
|
// move to it. The home org stays on X-User-Owner so identity never moves with the
|
|
// selection.
|
|
func TestOrg_MemberSelectionPays(t *testing.T) {
|
|
org, home, billed := orgRequest(t, memberClaims(), "beta-team")
|
|
if org != "beta-team" {
|
|
t.Errorf("X-Org-Id = %q, want beta-team (honored member selection)", org)
|
|
}
|
|
if home != "acme" {
|
|
t.Errorf("X-User-Owner = %q, want acme (home never moves)", home)
|
|
}
|
|
if billed != "beta-team/alice" {
|
|
t.Errorf("billed %q, want beta-team/alice — the SELECTED org must be the payer", billed)
|
|
}
|
|
}
|
|
|
|
// TestOrg_NonMemberReadsNothing is the tenant-isolation proof. A caller naming an
|
|
// org outside its signed membership set gets its OWN org in every respect: the
|
|
// backend is told the caller's org, and commerce is asked about the caller's
|
|
// ledger. Nothing of the requested org — not a header, not a balance lookup —
|
|
// survives the edge.
|
|
func TestOrg_NonMemberReadsNothing(t *testing.T) {
|
|
org, home, billed := orgRequest(t, memberClaims(), "victim")
|
|
if org != "acme" {
|
|
t.Errorf("SECURITY: X-Org-Id = %q, want acme (a non-member selection must fail closed to home)", org)
|
|
}
|
|
if home != "acme" {
|
|
t.Errorf("X-User-Owner = %q, want acme", home)
|
|
}
|
|
if billed != "acme/alice" {
|
|
t.Errorf("SECURITY: billed %q, want acme/alice — a non-member must never touch the requested org's ledger", billed)
|
|
}
|
|
if u, _ := url.QueryUnescape(billed); u == "victim/alice" {
|
|
t.Fatal("SECURITY: the requested org reached commerce")
|
|
}
|
|
}
|
|
|
|
// TestOrg_MasqueradeSpendsAdminLedger pins the operator case, where the two org
|
|
// questions deliberately give different answers: a human platform admin may view
|
|
// any org (X-Org-Id moves to the customer) but must never spend the customer's
|
|
// money (the balance gate stays on the admin ledger).
|
|
func TestOrg_MasqueradeSpendsAdminLedger(t *testing.T) {
|
|
operator := validClaims("https://hanzo.id", "https://api.hanzo.ai")
|
|
operator.Owner = "admin"
|
|
operator.RegisteredClaims.Subject = "z"
|
|
// A user token always carries its home org first (store.MemberOrgRefs), which is
|
|
// what distinguishes an operator from an app in the same org.
|
|
operator.Orgs = []authz.Membership{{Org: "admin", Role: "admin"}}
|
|
|
|
org, home, billed := orgRequest(t, operator, "customer")
|
|
if org != "customer" {
|
|
t.Errorf("X-Org-Id = %q, want customer (an operator may view any org)", org)
|
|
}
|
|
if home != "admin" {
|
|
t.Errorf("X-User-Owner = %q, want admin", home)
|
|
}
|
|
if billed != "admin/z" {
|
|
t.Errorf("billed %q, want admin/z — an operator must never spend the customer's ledger", billed)
|
|
}
|
|
}
|
|
|
|
// TestOrg_MachineCannotMasquerade closes the escalation the operator branch would
|
|
// otherwise open: a client_credentials identity that happens to live in the admin
|
|
// org is NOT a platform operator. Without the human narrowing, any admin-org
|
|
// machine app could name a victim org and the edge would write it for every backend
|
|
// that trusts the header by design.
|
|
func TestOrg_MachineCannotMasquerade(t *testing.T) {
|
|
machine := validClaims("https://hanzo.id", "https://api.hanzo.ai")
|
|
machine.Owner = "admin"
|
|
machine.RegisteredClaims.Subject = "admin/kms-sync"
|
|
// IAM's client_credentials grant signs NO membership set — that is the machine
|
|
// signal. The fixture used to set tokenType "application", a value IAM assigns
|
|
// nowhere, so this test passed against a token that cannot exist while the real
|
|
// escalation stayed open.
|
|
machine.Orgs = nil
|
|
|
|
org, _, billed := orgRequest(t, machine, "victim")
|
|
// NO org, which is stricter than pinning it to its own: a machine's org lives only
|
|
// in the `owner` claim, and that claim cannot tell "this machine's own org" from
|
|
// "the app a human signed in through". Whoever positively identifies a machine
|
|
// supplies its org; nothing is read from a claim that cannot distinguish them.
|
|
if org != "" {
|
|
t.Errorf("SECURITY: X-Org-Id = %q, want empty (a machine is not an operator)", org)
|
|
}
|
|
if billed != "admin/kms-sync" {
|
|
t.Errorf("billed %q, want admin/kms-sync", billed)
|
|
}
|
|
}
|
|
|
|
// relayOrg drives one request through the ZAP relay gate — the transport real
|
|
// client traffic arrives on (build_app.go RegisterRelay) — with the given claims
|
|
// and client selection, and returns the org the gate put on the envelope.
|
|
// forward.Forward.TenantID IS X-Org-Id: the relay lifts it inbound and re-emits it
|
|
// as that header at the backend, so this is the same contract on a second wire.
|
|
func relayOrg(t *testing.T, claims authz.Claims, selected string) string {
|
|
t.Helper()
|
|
tj := newTestJWKS(t)
|
|
cfg, closeJWKS := gateConfig(t, tj, true)
|
|
defer closeJWKS()
|
|
|
|
f := forwardWithHeaders(t, "/v1/chat/completions", map[string][]string{
|
|
"Authorization": {"Bearer " + tj.signToken(t, claims)},
|
|
})
|
|
f.TenantID = selected // as forward.Forwarder lifts it from the client's X-Org-Id
|
|
|
|
deny, err := newGate(cfg)(context.Background(), &f)
|
|
if err != nil || deny != nil {
|
|
t.Fatalf("relay denied a valid JWT: deny=%v err=%v", deny, err)
|
|
}
|
|
return f.TenantID
|
|
}
|
|
|
|
// TestOrg_RelayHonorsSelection proves the org switcher survives the ZAP relay on
|
|
// the same terms as the HTTP wire: a member's selection is honored, a non-member's
|
|
// is discarded to home, and a selection with no membership claim at all cannot
|
|
// move. Cloud re-validates the same Bearer behind this, so what the gate puts on
|
|
// the envelope is exactly what cloud's own membership gate gets to see — the whole
|
|
// reason the selection has to survive here at all.
|
|
func TestOrg_RelayHonorsSelection(t *testing.T) {
|
|
legacy := memberClaims()
|
|
legacy.Orgs = nil
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
claims authz.Claims
|
|
selected string
|
|
want string
|
|
}{
|
|
{"no selection → home", memberClaims(), "", "acme"},
|
|
{"member team → honored", memberClaims(), "beta-team", "beta-team"},
|
|
{"non-member → fail closed to home", memberClaims(), "victim", "acme"},
|
|
{"no orgs claim → no org at all", legacy, "beta-team", ""},
|
|
} {
|
|
if got := relayOrg(t, tc.claims, tc.selected); got != tc.want {
|
|
t.Errorf("%s: relay TenantID = %q, want %q", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|