mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
The relay gate and the money bits asked authz's PlatformSudo, which granted platform authority to an admin membership held at any position, so a brand org's member added to admin got X-User-IsAdmin and the Admin bit here while cloud and IAM said no. authz v1.10.47 has no PlatformSudo; both transports now ask Claims.Sudo. cmd/admin-api asked the owner CLAIM, the org of the application that minted the token, behind an IAM_ADMIN_ORG knob. A bearer is now asked Sudo, and a session the server's isSuperAdmin verdict. The knob and the row-level is_global_admin column, a copy of the predicate nothing read, are gone. authz v1.10.47 brings zip v1.37.3, whose untyped routes are Raw, and brand orgs that open only to the people who run them, so the fixtures' plain member now lives in a customer org.
178 lines
6.3 KiB
Go
178 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/gateway/v2/iam"
|
|
"github.com/hanzoai/gateway/v2/token"
|
|
)
|
|
|
|
// fakeCloudAPI stands in for cloud's GET /v1/ai/account. It echoes a
|
|
// fixed account so the data-layer gate resolves a browser session exactly as
|
|
// it does in production.
|
|
func fakeCloudAPI(t *testing.T, account map[string]any) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/ai/account" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "data": account})
|
|
}))
|
|
}
|
|
|
|
// fakeIAM stands in for the in-cluster IAM the aggregator fans out to.
|
|
//
|
|
// It answers the RESOURCE addresses and the shape they really carry: the body is
|
|
// the list itself, under a key named for the resource, and nothing is wrapped.
|
|
// Any other address is a test failure rather than a 404 — a fixture that quietly
|
|
// served whatever it was asked is how a client kept agreeing with a fixture while
|
|
// production answered 410.
|
|
func fakeIAM(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case iam.Applications:
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"applications": []map[string]any{{"owner": "admin", "name": "hanzo-cloud", "clientId": "abc123"}},
|
|
})
|
|
case iam.Roles:
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"roles": []map[string]any{{"owner": "admin", "name": "operators"}},
|
|
"total": 1,
|
|
})
|
|
case iam.AuditLogs:
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"auditLogs": []map[string]any{{"owner": "admin", "action": "login"}},
|
|
"total": 1,
|
|
})
|
|
default:
|
|
t.Errorf("aggregator asked IAM for %s, which IAM does not serve", r.URL.Path)
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
}
|
|
|
|
func newTestServer(cloudURL, iamURL string) *server {
|
|
cfg := &config{
|
|
cloudURL: cloudURL,
|
|
iamInternal: iamURL,
|
|
validator: token.NewValidator(token.Config{}), // no token in tests → ErrNoToken → session path
|
|
}
|
|
return &server{cfg: cfg, agg: &aggregator{cfg: cfg}}
|
|
}
|
|
|
|
// request through the real gate, carrying a browser session cookie so
|
|
// sessionIdentity (the path dave's browser uses) runs.
|
|
func gatedGet(srv *server, h http.HandlerFunc, path string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("Cookie", "hanzo_session=opaque")
|
|
rec := httptest.NewRecorder()
|
|
srv.gate(h)(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// The core privilege boundary: a tenant org-admin must be denied god-mode
|
|
// data at the data layer, independent of the SPA gate. This is the backend
|
|
// half of the "dave" fix.
|
|
func TestGate_DeniesTenantOrgAdmin(t *testing.T) {
|
|
cloud := fakeCloudAPI(t, map[string]any{"owner": "acme", "name": "dave", "isAdmin": true, "type": "normal-user"})
|
|
defer cloud.Close()
|
|
idp := fakeIAM(t)
|
|
defer idp.Close()
|
|
srv := newTestServer(cloud.URL, idp.URL)
|
|
|
|
for _, path := range []string{"/v1/admin/applications", "/v1/admin/roles", "/v1/admin/overview"} {
|
|
rec := gatedGet(srv, srv.handleApplications, path)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("%s: tenant org-admin should be 403, got %d (%s)", path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// The known misconfig angle: a `hanzo` TENANT org-admin must STILL be denied
|
|
// here — an org's own admin is never a SuperAdmin, whatever a deployment value
|
|
// once said about which orgs were "admin" ones.
|
|
func TestGate_DeniesHanzoTenantOrg(t *testing.T) {
|
|
cloud := fakeCloudAPI(t, map[string]any{"owner": "hanzo", "name": "dave", "isAdmin": true, "type": "normal-user"})
|
|
defer cloud.Close()
|
|
idp := fakeIAM(t)
|
|
defer idp.Close()
|
|
srv := newTestServer(cloud.URL, idp.URL)
|
|
|
|
rec := gatedGet(srv, srv.handleApplications, "/v1/admin/applications")
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("hanzo tenant org-admin should be 403, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestGate_DeniesAnonymous(t *testing.T) {
|
|
cloud := fakeCloudAPI(t, map[string]any{"owner": "admin", "type": "anonymous-user"})
|
|
defer cloud.Close()
|
|
idp := fakeIAM(t)
|
|
defer idp.Close()
|
|
srv := newTestServer(cloud.URL, idp.URL)
|
|
|
|
rec := gatedGet(srv, srv.handleApplications, "/v1/admin/applications")
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("anonymous session should be 403, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestGate_DeniesNoCookie(t *testing.T) {
|
|
cloud := fakeCloudAPI(t, map[string]any{"owner": "admin", "isAdmin": true})
|
|
defer cloud.Close()
|
|
idp := fakeIAM(t)
|
|
defer idp.Close()
|
|
srv := newTestServer(cloud.URL, idp.URL)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/admin/applications", nil)
|
|
rec := httptest.NewRecorder()
|
|
srv.gate(srv.handleApplications)(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("no-cookie request should be 403, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// A SuperAdmin — the server's verdict on the session — passes the gate and gets
|
|
// the live IAM applications via the unified /v1/admin/* surface.
|
|
func TestGate_AllowsSuperAdmin_Applications(t *testing.T) {
|
|
cloud := fakeCloudAPI(t, map[string]any{"owner": "admin", "name": "root", "isAdmin": true, "type": "normal-user", "isSuperAdmin": true})
|
|
defer cloud.Close()
|
|
idp := fakeIAM(t)
|
|
defer idp.Close()
|
|
srv := newTestServer(cloud.URL, idp.URL)
|
|
|
|
rec := gatedGet(srv, srv.handleApplications, "/v1/admin/applications")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("SuperAdmin should be 200, got %d (%s)", rec.Code, rec.Body.String())
|
|
}
|
|
var env struct {
|
|
Status string `json:"status"`
|
|
Data []map[string]any `json:"data"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if env.Status != "ok" || len(env.Data) != 1 || env.Data[0]["name"] != "hanzo-cloud" || env.Total != 1 {
|
|
t.Fatalf("unexpected body: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGate_AllowsSuperAdmin_Roles(t *testing.T) {
|
|
cloud := fakeCloudAPI(t, map[string]any{"owner": "admin", "name": "root", "isAdmin": true, "type": "normal-user", "isSuperAdmin": true})
|
|
defer cloud.Close()
|
|
idp := fakeIAM(t)
|
|
defer idp.Close()
|
|
srv := newTestServer(cloud.URL, idp.URL)
|
|
|
|
rec := gatedGet(srv, srv.handleRoles, "/v1/admin/roles")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("SuperAdmin should be 200, got %d (%s)", rec.Code, rec.Body.String())
|
|
}
|
|
}
|