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.
135 lines
5.4 KiB
Go
135 lines
5.4 KiB
Go
// Gateway-upstream assertion and request-context helpers.
|
|
//
|
|
// Every Base-derived service MUST sit behind hanzoai/gateway. The gateway is
|
|
// the sole JWT verifier in the stack. Services refuse to serve tenant-scoped
|
|
// routes unless this invariant is explicitly acknowledged at boot.
|
|
//
|
|
// Usage in a service:
|
|
//
|
|
// if err := claims.AssertGatewayUpstream(); err != nil {
|
|
// log.Fatal(err) // boot refuses
|
|
// }
|
|
// mux.Use(claims.Strip) // defense-in-depth: drop forged headers
|
|
// mux.Use(claims.RequireGateway) // 503 if headers are missing on tenant routes
|
|
// mux.Use(claims.Inject) // pull Claims into ctx
|
|
package claims
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
)
|
|
|
|
// EnvGatewayUpstream is the environment variable that acknowledges the service
|
|
// is reachable only through hanzoai/gateway. Any value other than "1" / "true"
|
|
// triggers a boot refusal.
|
|
const EnvGatewayUpstream = "HANZO_GATEWAY_UPSTREAM"
|
|
|
|
// ErrGatewayBypass is returned / surfaced when a tenant-scoped request reaches
|
|
// a handler without the canonical identity headers. It maps to HTTP 503; 401
|
|
// would suggest the client can recover by authenticating, which is wrong —
|
|
// the deployment topology is broken, not the caller.
|
|
var ErrGatewayBypass = errors.New("claims: gateway bypass detected — canonical identity headers missing")
|
|
|
|
// ErrGatewayNotAsserted is returned at boot when HANZO_GATEWAY_UPSTREAM is
|
|
// unset or falsy.
|
|
var ErrGatewayNotAsserted = errors.New("claims: HANZO_GATEWAY_UPSTREAM must be set to 1 — apps never re-verify JWT")
|
|
|
|
// AssertGatewayUpstream returns an error if the gateway-upstream acknowledgement
|
|
// is missing. Call exactly once at service boot, before listening.
|
|
func AssertGatewayUpstream() error {
|
|
switch os.Getenv(EnvGatewayUpstream) {
|
|
case "1", "true", "TRUE", "True":
|
|
return nil
|
|
default:
|
|
return ErrGatewayNotAsserted
|
|
}
|
|
}
|
|
|
|
// ctxKey is an unexported key type to prevent foreign packages from injecting
|
|
// Claims into the context by accident. The Claims context is produced only by
|
|
// claims.Inject.
|
|
type ctxKey struct{}
|
|
|
|
// Inject is a middleware that parses the canonical 3 identity headers and
|
|
// attaches the resulting Claims to the request context. It does NOT validate
|
|
// presence — pair it with RequireGateway for tenant-scoped routes.
|
|
func Inject(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
c := FromHeaders(r)
|
|
ctx := context.WithValue(r.Context(), ctxKey{}, c)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// FromContext returns the verified Claims attached by Inject. Returns the
|
|
// zero Claims{} if none were attached (e.g. public route).
|
|
func FromContext(ctx context.Context) Claims {
|
|
c, _ := ctx.Value(ctxKey{}).(Claims)
|
|
return c
|
|
}
|
|
|
|
// OrgID is a thin convenience that returns the caller's org slug.
|
|
func OrgID(ctx context.Context) string { return FromContext(ctx).OrgID }
|
|
|
|
// UserID is a thin convenience that returns the caller's user id.
|
|
func UserID(ctx context.Context) string { return FromContext(ctx).UserID }
|
|
|
|
// HasRole reports whether the caller holds any of the requested roles.
|
|
func HasRole(ctx context.Context, role ...string) bool {
|
|
return FromContext(ctx).HasRole(role...)
|
|
}
|
|
|
|
// RequireGateway is a middleware for tenant-scoped routes. If either X-User-Id
|
|
// or X-Org-Id is missing after Strip + Inject have run, the gateway was
|
|
// bypassed (misconfigured ingress, direct pod access, etc.) and the handler
|
|
// MUST NOT serve. Returns 503 with a neutral body (no hint about which header
|
|
// was missing — that is an attacker oracle).
|
|
func RequireGateway(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
c := FromContext(r.Context())
|
|
if c.UserID == "" || c.OrgID == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
// Intentionally vague. Operators get the detail in logs, not wire.
|
|
_, _ = w.Write([]byte(`{"error":"service unavailable"}`))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// Chain is the canonical tenant-route middleware chain: Strip → Inject →
|
|
// RequireGateway → next. This is the ONLY approved way to mount a
|
|
// tenant-scoped handler. Services MUST NOT compose Strip, Inject, and
|
|
// RequireGateway by hand — the PHILOSOPHY.md "one and only one way"
|
|
// principle applies, and a misordered hand-wired chain silently defeats
|
|
// the forged-header defense (verified by Red probe P7-H3).
|
|
//
|
|
// Public routes (/healthz, /readyz, /metrics) MUST be mounted on a
|
|
// separate mux that does not pass through Chain; the chain would 503
|
|
// every probe when gateway headers are absent.
|
|
func Chain(next http.Handler) http.Handler {
|
|
return Strip(Inject(RequireGateway(next)))
|
|
}
|
|
|
|
// RequireRole returns a middleware that enforces the caller holds at least one
|
|
// of the requested roles. On failure, the response is a 404 — not 403 — so
|
|
// probing for authorized endpoints does not leak their existence. If you want
|
|
// an explicit 403 for a user that IS in the tenant but lacks a role, check
|
|
// HasRole inside the handler instead.
|
|
func RequireRole(role ...string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !HasRole(r.Context(), role...) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":"not found"}`))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|