Files
gateway/cmd/admin-guard/brand_test.go
zeekayandHanzo Dev 2d406b9b18 admin-guard: separate the POLICY from the identity resolver
The guard asked exactly one question — "is this an admin of this
surface's org, or platform sudo?" — and every surface wired to it
inherited that question whether or not it was the right one. So
functions.hanzo.ai, api.hanzo.ai/v1/training and kms/admin all shipped
admin-only "safe minimal" gates, and ci.hanzo.ai could not be wired at
all: a per-org CI dashboard gated on global sudo is not a dashboard its
users can open.

The fix is a separation, not a loosening. Identity resolution (the
three-source cascade: guard cookie, Bearer/Basic JWT, IAM session) is
identical for every surface and stays in one place. What differs is the
question asked of the resolved principal, so that becomes a `policy`
value and handleVerify takes it as a parameter:

  adminPolicy — unchanged. c.authorize(p, host): sudo anywhere, or
    owner/admin of the org an admin.<brand> host belongs to.
  authnPolicy — new. Any principal that resolved with a non-empty owner.
    Host-independent on purpose: it grants REACH, never visibility.

Which policy applies is decided by WHICH ENDPOINT the ingress middleware
points at (/__guard/verify vs the new /__guard/verify-authn). That is
config, not request data, so nothing a caller sends can downgrade the
gate on an admin surface.

authnPolicy refuses an empty owner rather than passing it through. An
authn surface scopes its data by the X-Org-Id the guard mints from the
verified `owner` claim, so a blank owner would mean a blank scope — and
a surface that renders "all orgs" for a blank scope would publish every
org's data to that caller. Fail closed on the exact field the downstream
trusts.

Consequence for wiring: attach verify-authn ONLY to a surface that
actually scopes by X-Org-Id. Attaching it to one that ignores the header
publishes that surface to every authenticated user.

Tests are a matched pair, because proving only the new half would be
indistinguishable from having quietly opened every admin surface:
TestAuthnPolicyAdmitsPlainOrgUser (a non-admin lux user reaches
ci.hanzo.ai, X-Org-Id=lux) and TestAdminPolicyStillRefusesPlainOrgUser
(the SAME principal on the SAME host still gets 403). The second was
mutation-verified — loosening adminPolicy to authnPolicy's predicate
makes it fail 204-want-403.

X-Admin-Guard now reports allow-<policy>. Nothing consumes it (it is in
no authResponseHeaders, so it never leaves the guard); it is diagnostic,
and naming the policy that produced a verdict is worth more than the
bare "allow".

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 23:31:56 -07:00

178 lines
6.5 KiB
Go

// Copyright 2023-2025 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestRegistrableDomain is gap #1: the guard cookie is scoped to the REGISTRABLE
// domain of each request's host, so one binary white-labels every brand instead
// of pinning ".hanzo.ai" (which a browser on admin.zoo.cloud silently drops).
func TestRegistrableDomain(t *testing.T) {
cases := map[string]string{
"admin.hanzo.ai": ".hanzo.ai",
"functions.hanzo.ai": ".hanzo.ai",
"admin.zoo.cloud": ".zoo.cloud",
"admin.lux.network": ".lux.network",
"hanzo.ai": ".hanzo.ai",
"admin.zoo.cloud:443": ".zoo.cloud", // port stripped
"localhost": "", // single label → host-only cookie
}
for host, want := range cases {
if got := registrableDomain(host); got != want {
t.Errorf("registrableDomain(%q)=%q want %q", host, got, want)
}
}
}
// TestConsoleFor is the non-admin brand-routing half: zoo→zoo console,
// lux→lux console, unknown→default. Lux's console is on lux.cloud, not
// lux.network — the derivation keys off the guarded host's registrable domain.
func TestConsoleFor(t *testing.T) {
c := testConfig()
cases := map[string]string{
"admin.hanzo.ai": "https://console.hanzo.ai",
"functions.hanzo.ai": "https://console.hanzo.ai",
"admin.zoo.cloud": "https://console.zoo.cloud",
"admin.lux.network": "https://console.lux.cloud",
"admin.example.test": "https://console.hanzo.ai", // unknown → default
}
for host, want := range cases {
if got := c.consoleFor(host); got != want {
t.Errorf("consoleFor(%q)=%q want %q", host, got, want)
}
}
}
// TestBrandCookieDomainAndConsole exercises the live forward-auth paths per
// brand: (a) an anonymous browser gets the PKCE state cookie scoped to ITS host's
// registrable domain (so it sticks and the callback can read it), and (b) an
// authenticated non-admin is bounced to ITS brand's console. Admin is global —
// an admin cookie is allowed on every brand host.
func TestBrandCookieDomainAndConsole(t *testing.T) {
cfg := testConfig()
brands := []struct {
host string
wantCookieDom string
wantConsole string
}{
{"admin.hanzo.ai", "hanzo.ai", "https://console.hanzo.ai"},
{"admin.zoo.cloud", "zoo.cloud", "https://console.zoo.cloud"},
{"admin.lux.network", "lux.network", "https://console.lux.cloud"},
}
for _, b := range brands {
t.Run(b.host+"/anon-state-cookie", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/__guard/verify", nil)
r.Header.Set("X-Forwarded-Proto", "https")
r.Header.Set("X-Forwarded-Host", b.host)
r.Header.Set("X-Forwarded-Uri", "/")
r.Header.Set("Accept", "text/html")
w := httptest.NewRecorder()
cfg.handleVerify(w, r, adminPolicy)
if w.Code != http.StatusFound {
t.Fatalf("anon browser: status=%d want 302", w.Code)
}
// redirect_uri must point back at THIS host's callback (gap #2:
// each such URI must be registered on the IAM app).
loc := w.Header().Get("Location")
wantRU := "redirect_uri=https%3A%2F%2F" + b.host + "%2F__guard%2Fcallback"
if !strings.Contains(loc, wantRU) {
t.Errorf("authorize redirect_uri wrong for %s:\n loc=%s", b.host, loc)
}
// The state cookie MUST be scoped to this host's registrable domain,
// or the browser drops it and login loops.
var state *http.Cookie
for _, ck := range w.Result().Cookies() {
if ck.Name == stateCookie {
state = ck
}
}
if state == nil {
t.Fatal("no state cookie set")
}
if state.Domain != b.wantCookieDom {
t.Errorf("state cookie Domain=%q want %q (browser on %s would drop a foreign-domain cookie)", state.Domain, b.wantCookieDom, b.host)
}
})
t.Run(b.host+"/nonadmin-console", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/__guard/verify", nil)
r.Header.Set("X-Forwarded-Host", b.host)
r.Header.Set("Accept", "text/html")
r.AddCookie(cfg.guardCookie("acme", false)) // authed, non-admin
w := httptest.NewRecorder()
cfg.handleVerify(w, r, adminPolicy)
if w.Code != http.StatusFound {
t.Fatalf("non-admin: status=%d want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != b.wantConsole {
t.Errorf("non-admin on %s → %q want %q (brand console)", b.host, loc, b.wantConsole)
}
})
t.Run(b.host+"/admin-allow", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/__guard/verify", nil)
r.Header.Set("X-Forwarded-Host", b.host)
r.Header.Set("Accept", "text/html")
r.AddCookie(cfg.guardCookie("admin", true)) // global admin
w := httptest.NewRecorder()
cfg.handleVerify(w, r, adminPolicy)
if w.Code != http.StatusNoContent {
t.Fatalf("admin on %s: status=%d want 204 (global admin allowed on every brand)", b.host, w.Code)
}
})
}
}
// A brand's admin surface must send an anonymous browser to ITS OWN IAM. When
// the login authority was pinned to hanzo.id, admin.lux.network and
// admin.zoo.cloud rendered "Sign in to Hanzo ID" — Hanzo branding on a Lux/Zoo
// surface, which the white-label rule forbids outright.
func TestIAMForIsWhiteLabelledByHost(t *testing.T) {
c := &config{
iams: parseBrandMap(defaultIAMMap),
iamPublic: "https://hanzo.id",
}
for host, want := range map[string]string{
"admin.hanzo.ai": "https://hanzo.id",
"admin.lux.network": "https://lux.id",
"admin.lux.cloud": "https://lux.id",
"admin.zoo.cloud": "https://zoo.id",
"admin.zoo.ngo": "https://zoo.id",
"admin.pars.network": "https://pars.id",
"admin.bootno.de": "https://id.bootno.de",
// An unrecognized host falls back, never to a foreign brand's IAM.
"admin.example.test": "https://hanzo.id",
} {
if got := c.iamFor(host); got != want {
t.Errorf("iamFor(%q)=%q want %q", host, got, want)
}
}
}
// No brand may resolve to another brand's login authority.
func TestIAMForNeverCrossesBrands(t *testing.T) {
c := &config{iams: parseBrandMap(defaultIAMMap), iamPublic: "https://hanzo.id"}
for _, tc := range []struct{ host, forbidden string }{
{"admin.lux.network", "hanzo.id"},
{"admin.lux.cloud", "hanzo.id"},
{"admin.zoo.cloud", "hanzo.id"},
{"admin.zoo.network", "hanzo.id"},
{"admin.pars.network", "hanzo.id"},
} {
if got := c.iamFor(tc.host); strings.Contains(got, tc.forbidden) {
t.Errorf("iamFor(%q)=%q must not use %q", tc.host, got, tc.forbidden)
}
}
}