mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
* feat(base): per-(user,org) multitenant store + gateway-upstream middleware
claims: AssertGatewayUpstream (boot refusal unless HANZO_GATEWAY_UPSTREAM=1),
Inject / RequireGateway / RequireRole middleware wired onto the canonical
3-header contract (X-User-Id, X-Org-Id, X-Roles). RequireGateway returns
503 (deployment topology bug, not an auth failure). RequireRole returns
404 (existence-leak defense). 18 subtests pass.
store: MultiTenantStore holds one SQLite handle per (org, user) in a luxfi/
cache LRU, hydrates on miss from hanzoai/base/tools/filesystem (S3/GCS/
local), checkpoints dirty handles to object storage on idle-TTL, LRU-cap,
explicit Evict, and SIGTERM. Org-wide DB via ForOrg. 13 tests pass (miss,
hit, isolation, checkpoint-uploads, eviction-rehydrates, close-flushes,
concurrent-readers, gateway-bypass). Works around luxfi/container v0.0.2
onEvict-silently-dropped bug via explicit shadow set + flushAndCloseLocked.
* fix(claims): reject CTL bytes + cap slug length on identity headers (P7-H3/H4/M1)
FromHeaders now sanitizes X-User-Id, X-Org-Id, and each X-Roles element
through hasControlByte + MaxIdentityValueLen=256. Any header containing
a byte below 0x20 or equal to 0x7f, or exceeding the length cap, is
discarded — the field becomes "" and RequireGateway 503s instead of
forwarding attacker-controlled bytes into log / path / audit code.
parseRoles gains the same defense at the per-role level: CRLF-smuggled
roles are dropped without breaking the rest of the list.
Red P7-H3 / P7-H4 / P7-M2 verified: CRLF response-splitting primitive
and NUL / TAB / zero-width / DEL injection primitives all closed.
Tests:
TestFromHeaders_RejectsControlBytesInSlug (6 subcases)
TestFromHeaders_RejectsOverlongValue
TestParseRoles_DropsCRLFRoles
go test -race -count=3 ./tools/claims/... green.
* feat(claims): Chain(h) canonical middleware order enforcement (P7-H3)
Chain is the sole approved way to mount a tenant-scoped handler:
mux.Handle("/v1/", claims.Chain(tenant))
It wires Strip → Inject → RequireGateway in the required order. Services
MUST NOT compose the three middlewares by hand — the PHILOSOPHY.md
"one and only one way" principle applies. A misordered hand-wired chain
silently defeats the forged-header defense (verified by Red probe
P7-H3: Inject before Strip allowed attacker headers through).
Tests:
TestChain_EnforcesCanonicalOrder
TestChain_DirectAccess_IsRejected
go test -race -count=3 ./tools/claims/... green.
* fix(store): P7 hardening — SQLite magic, upload errors, chunked reaper, slug cap, CAS=false
Bundles the six Red P7 findings on store/multitenant.go that touch the
same file into one atomic review unit. Each sub-fix has its own test and
its own commentary in the code so the diff reads as six independent
changes with no cross-dependencies.
P7-C1 (option B — sticky-session v1)
Package doc spells out: single-writer per (org,user) is provided by
gateway sticky-session affinity on X-Org-Id, NOT by object-storage CAS.
Exports `CAS = false` so any consumer that requires generation-checked
writes can feature-gate and refuse to boot until CAS flips true. The
ARCHITECTURE.md §5.5 reconciliation lands in the next commit.
Test: TestCAS_ConstantExposed.
P7-C2 (object-store poisoning)
Hydrate now validates the SQLite 3 magic (`SQLite format 3\x00`) on
the downloaded blob BEFORE handing bytes to modernc.org/sqlite. A
non-SQLite blob (or short/truncated) is rejected with ErrCorruptDB
and the local file is removed — the poisoned bytes never reach the
driver, so hostile sqlite_master rows cannot fire on the next query.
Empty blobs (fresh-tenant slot) are allowed.
Tests: TestHydrate_RejectsPoisonedBlob, TestValidateSQLiteMagic_AcceptsEmpty.
P7-H1 (silent upload failures)
ErrUploadFailed is the canonical sentinel. flushAndCloseLocked now
returns error; Evict propagates it; Close aggregates via errors.Join
and reports "flushed N/M, K upload failures". On upload failure the
handle is re-opened and retained in the LRU + shadow so the next
reap/Checkpoint can retry — no silent data loss. The LRU-cap-eviction
path inside hydrate surfaces the same error instead of falling back
to a different victim (which would have silently dropped the retry).
OnReapFailure is a new observer hook on Options for ops to wire a
structured logger or Prometheus counter to reap-cycle failures.
Tests: TestEvict_SurfacesUploadError, TestClose_AggregatesUploadFailures,
TestEvictionReturnsError_RecoversOnRetry.
P7-H2 (reaper O(N) under s.mu)
reapOnce now snapshots idle keys under s.mu, RELEASES the lock, and
then calls s.Evict per-key (each Evict reacquires briefly). Close
follows the same pattern. A cold Get for a new tenant made during a
reap blocks for a single handle's flush latency, not the entire
reap cycle. Documents the lock discipline on both methods.
Test: TestReaper_LockHoldTimeChunked (fills 100 handles, probes
latency; must stay < 500ms).
P7-M1 (slug length)
MaxSlugLen = 128. validateSlug rejects overlong inputs before any FS
call; the attacker-controlled slug no longer reaches the error
message with ENAMETOOLONG.
Test: TestKey_Valid_RejectsLongSlug.
Test-infra
newTestStore default IdleTTL bumped to 5s so standard-flow tests
aren't racing the reaper while operating on a DB handle. Tests that
exercise reap/idle behavior pass a short IdleTTL via the variadic.
go test -race -count=3 ./store/... green: 21 tests pass.
206 lines
6.5 KiB
Go
206 lines
6.5 KiB
Go
package claims_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/base/tools/claims"
|
|
)
|
|
|
|
func TestAssertGatewayUpstream_RefusesUnset(t *testing.T) {
|
|
t.Setenv(claims.EnvGatewayUpstream, "")
|
|
if err := claims.AssertGatewayUpstream(); err == nil {
|
|
t.Fatal("expected error when HANZO_GATEWAY_UPSTREAM is unset")
|
|
}
|
|
}
|
|
|
|
func TestAssertGatewayUpstream_RefusesFalsy(t *testing.T) {
|
|
for _, v := range []string{"0", "false", "no", "yes", "on"} {
|
|
t.Run(v, func(t *testing.T) {
|
|
t.Setenv(claims.EnvGatewayUpstream, v)
|
|
if err := claims.AssertGatewayUpstream(); err == nil {
|
|
t.Fatalf("expected error for %q", v)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAssertGatewayUpstream_AcceptsTruthy(t *testing.T) {
|
|
for _, v := range []string{"1", "true", "TRUE", "True"} {
|
|
t.Run(v, func(t *testing.T) {
|
|
t.Setenv(claims.EnvGatewayUpstream, v)
|
|
if err := claims.AssertGatewayUpstream(); err != nil {
|
|
t.Fatalf("expected nil for %q, got %v", v, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInject populates context with canonical Claims and exposes them via
|
|
// FromContext/OrgID/UserID/HasRole.
|
|
func TestInject_Populates(t *testing.T) {
|
|
var seen claims.Claims
|
|
var seenOrg, seenUser string
|
|
var seenRole bool
|
|
|
|
h := claims.Inject(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = claims.FromContext(r.Context())
|
|
seenOrg = claims.OrgID(r.Context())
|
|
seenUser = claims.UserID(r.Context())
|
|
seenRole = claims.HasRole(r.Context(), "admin")
|
|
}))
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set(claims.HeaderUserID, "u_alice")
|
|
r.Header.Set(claims.HeaderOrgID, "hanzo")
|
|
r.Header.Set(claims.HeaderRoles, "admin,viewer")
|
|
|
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
|
|
|
if seen.UserID != "u_alice" || seen.OrgID != "hanzo" {
|
|
t.Fatalf("FromContext = %+v", seen)
|
|
}
|
|
if seenUser != "u_alice" || seenOrg != "hanzo" {
|
|
t.Fatalf("UserID/OrgID = %q/%q", seenUser, seenOrg)
|
|
}
|
|
if !seenRole {
|
|
t.Fatal("HasRole(admin) should be true")
|
|
}
|
|
}
|
|
|
|
// FromContext on a bare context returns zero claims (not a panic).
|
|
func TestFromContext_Empty(t *testing.T) {
|
|
c := claims.FromContext(context.Background())
|
|
if c.UserID != "" || c.OrgID != "" || len(c.Roles) != 0 {
|
|
t.Fatalf("expected zero Claims, got %+v", c)
|
|
}
|
|
}
|
|
|
|
// RequireGateway returns 503 — NOT 401 — when identity headers are missing.
|
|
// 401 would suggest the caller can recover by authenticating; the real
|
|
// problem is a gateway bypass (deployment topology bug).
|
|
func TestRequireGateway_503OnMissing(t *testing.T) {
|
|
h := claims.Inject(claims.RequireGateway(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, r)
|
|
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// RequireGateway also 503s when only one of the two required headers is set.
|
|
func TestRequireGateway_503OnPartial(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
kv map[string]string
|
|
}{
|
|
{"only user", map[string]string{claims.HeaderUserID: "u"}},
|
|
{"only org", map[string]string{claims.HeaderOrgID: "o"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := claims.Inject(claims.RequireGateway(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("handler must not run")
|
|
})))
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
for k, v := range tc.kv {
|
|
r.Header.Set(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, r)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503, got %d", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// RequireGateway allows through when both required headers are present.
|
|
func TestRequireGateway_PassThrough(t *testing.T) {
|
|
called := false
|
|
h := claims.Inject(claims.RequireGateway(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set(claims.HeaderUserID, "u_alice")
|
|
r.Header.Set(claims.HeaderOrgID, "hanzo")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, r)
|
|
|
|
if !called {
|
|
t.Fatal("handler should have run")
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// RequireRole returns 404 (NOT 403) when the caller lacks the role. This is a
|
|
// deliberate existence-leak defense: a probe from a non-admin for an
|
|
// admin-only endpoint should be indistinguishable from a probe for a
|
|
// non-existent endpoint.
|
|
func TestRequireRole_404OnMissingRole(t *testing.T) {
|
|
h := claims.Inject(claims.RequireRole("admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("handler must not run")
|
|
})))
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set(claims.HeaderUserID, "u_alice")
|
|
r.Header.Set(claims.HeaderOrgID, "hanzo")
|
|
r.Header.Set(claims.HeaderRoles, "viewer")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, r)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// RequireRole lets the caller through when the role is held.
|
|
func TestRequireRole_PassThrough(t *testing.T) {
|
|
called := false
|
|
h := claims.Inject(claims.RequireRole("admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set(claims.HeaderUserID, "u")
|
|
r.Header.Set(claims.HeaderOrgID, "o")
|
|
r.Header.Set(claims.HeaderRoles, "admin,viewer")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, r)
|
|
if !called || rec.Code != http.StatusOK {
|
|
t.Fatalf("expected passthrough 200, got called=%v code=%d", called, rec.Code)
|
|
}
|
|
}
|
|
|
|
// Strip + Inject composes: a forged X-User-Id from the client is removed by
|
|
// Strip, leaving Inject to see an empty Claims; RequireGateway then 503s.
|
|
func TestStrip_ThenInject_DefeatsForgedHeaders(t *testing.T) {
|
|
h := claims.Strip(claims.Inject(claims.RequireGateway(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("handler must not run for forged identity")
|
|
}))))
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
// Attacker forges every identity header we know about.
|
|
r.Header.Set(claims.HeaderUserID, "attacker")
|
|
r.Header.Set(claims.HeaderOrgID, "victim")
|
|
r.Header.Set(claims.HeaderRoles, "admin")
|
|
r.Header.Set("X-Hanzo-User-Id", "attacker")
|
|
r.Header.Set("X-IAM-User-Id", "attacker")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, r)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503 after Strip removed forged headers, got %d", rec.Code)
|
|
}
|
|
}
|