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.8 KiB
Go
206 lines
6.8 KiB
Go
// Package claims provides the canonical 3-header identity contract for every
|
|
// Base-derived service. There is exactly ONE way to read the authenticated
|
|
// caller's identity: [FromHeaders].
|
|
//
|
|
// The Hanzo Gateway validates the IAM JWT upstream and re-emits exactly three
|
|
// headers on the forwarded request. Services MUST NOT read any other variant
|
|
// (no X-Hanzo-*, no X-IAM-*, no singular X-User-Role, no X-Tenant-Id alias):
|
|
//
|
|
// X-User-Id <- JWT "sub"
|
|
// X-Org-Id <- JWT "owner"
|
|
// X-Roles <- JWT "roles" (comma-joined if array)
|
|
//
|
|
// Services MUST call [StripIdentityHeaders] on every inbound request before
|
|
// the JWT middleware re-injects trusted values. A client that sets any of
|
|
// these headers directly is rejected at the gateway; services defend in depth
|
|
// by stripping again locally in case a sidecar/mesh bypasses the gateway.
|
|
package claims
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// The canonical 3 identity headers. These are the ONLY headers a handler may
|
|
// read to determine the authenticated principal.
|
|
const (
|
|
HeaderUserID = "X-User-Id"
|
|
HeaderOrgID = "X-Org-Id"
|
|
HeaderRoles = "X-Roles"
|
|
)
|
|
|
|
// MaxIdentityValueLen caps every header value consumed by this package.
|
|
// Chosen at 256 bytes: generous for ULIDs/UUIDs, IAM usernames, and the
|
|
// longest realistic comma-joined role list (~20 role names @ 12 chars).
|
|
// A value longer than this is either a bug or an exhaustion attempt and
|
|
// is discarded — the caller observes "no identity" and RequireGateway 503s.
|
|
const MaxIdentityValueLen = 256
|
|
|
|
// Claims is the verified identity of the current request as asserted by the
|
|
// upstream gateway's JWT validation. All three fields may be empty strings /
|
|
// empty slices when the request is unauthenticated (public endpoints).
|
|
type Claims struct {
|
|
UserID string
|
|
OrgID string
|
|
Roles []string
|
|
}
|
|
|
|
// FromHeaders returns the canonical Claims for the request. It reads ONLY the
|
|
// three canonical headers; any legacy variant set by a client is ignored by
|
|
// design (and should have been stripped upstream).
|
|
//
|
|
// Header values are sanitized: any value that contains a control character
|
|
// (byte < 0x20 or byte == 0x7f) or exceeds [MaxIdentityValueLen] bytes is
|
|
// discarded and the corresponding field becomes empty. This makes log /
|
|
// path / response-splitting injection unreachable through this parser, and
|
|
// makes [RequireGateway] fail closed (503) on a poisoned identity instead
|
|
// of forwarding hostile bytes into handlers.
|
|
//
|
|
// Roles are decoded from a comma-separated list; empty roles, roles that
|
|
// exceed the length cap individually, and roles that contain control
|
|
// characters are each dropped.
|
|
func FromHeaders(r *http.Request) Claims {
|
|
return Claims{
|
|
UserID: sanitizeIdentity(r.Header.Get(HeaderUserID)),
|
|
OrgID: sanitizeIdentity(r.Header.Get(HeaderOrgID)),
|
|
Roles: parseRoles(r.Header.Get(HeaderRoles)),
|
|
}
|
|
}
|
|
|
|
// sanitizeIdentity returns s unchanged when it meets the identity-value
|
|
// contract: non-empty, ≤ MaxIdentityValueLen bytes, no control characters.
|
|
// Any violation returns "" — i.e. the header is treated as absent. We
|
|
// deliberately do NOT fall back to a "best-effort" trimmed value because
|
|
// the identity contract is binary: either the gateway produced a clean
|
|
// header or the request is hostile.
|
|
func sanitizeIdentity(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if len(s) > MaxIdentityValueLen {
|
|
return ""
|
|
}
|
|
if hasControlByte(s) {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
// hasControlByte reports whether s contains any byte below 0x20 (all C0
|
|
// controls including NUL, TAB, CR, LF) or the DEL byte 0x7f. These bytes
|
|
// are never part of a legitimate IAM slug, email, or role name and are
|
|
// the universal primitives for header / log / path injection.
|
|
func hasControlByte(s string) bool {
|
|
for i := 0; i < len(s); i++ {
|
|
b := s[i]
|
|
if b < 0x20 || b == 0x7f {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// parseRoles splits a comma-joined roles header into a slice, trimming
|
|
// whitespace and dropping empty entries. Any role value that contains a
|
|
// control byte or exceeds the length cap is silently dropped — the parser
|
|
// refuses to propagate bytes that could smuggle CRLF or NUL into
|
|
// downstream audit / log / response code.
|
|
func parseRoles(raw string) []string {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
if len(raw) > MaxIdentityValueLen {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
v := strings.TrimSpace(p)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if hasControlByte(v) {
|
|
continue
|
|
}
|
|
if len(v) > MaxIdentityValueLen {
|
|
continue
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// HasRole reports whether the caller holds any of the requested roles.
|
|
// Role names are matched exactly (case-sensitive); empty inputs return false.
|
|
func (c Claims) HasRole(wanted ...string) bool {
|
|
for _, want := range wanted {
|
|
if want == "" {
|
|
continue
|
|
}
|
|
for _, got := range c.Roles {
|
|
if got == want {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// legacyIdentityHeaders is the full list of non-canonical identity-bearing
|
|
// headers that MUST be stripped from every inbound request. Include both the
|
|
// canonical 3 (so a client-supplied value cannot survive until a handler
|
|
// re-reads it before the gateway/JWT middleware has re-set them) and every
|
|
// historical variant the ecosystem has shipped.
|
|
var legacyIdentityHeaders = []string{
|
|
// Canonical 3 — stripped on ingress, re-injected after JWT validation.
|
|
HeaderUserID,
|
|
HeaderOrgID,
|
|
HeaderRoles,
|
|
// Gateway-emitted auxiliaries (derivatives of the JWT).
|
|
"X-User-Email",
|
|
"X-Phone-Number",
|
|
"X-User-IsAdmin",
|
|
// Non-canonical legacy identity headers.
|
|
"X-User-Role", // singular
|
|
"X-User-Roles", // plural — renamed to X-Roles
|
|
"X-User-Name",
|
|
"X-Tenant-Id",
|
|
"X-Tenant-ID",
|
|
"X-Org",
|
|
"X-Is-Admin",
|
|
// Pre-validation hints from older gateway flows.
|
|
"X-Gateway-Validated",
|
|
"X-Gateway-User-Id",
|
|
"X-Gateway-Org-Id",
|
|
"X-Gateway-User-Email",
|
|
}
|
|
|
|
// StripIdentityHeaders removes every inbound identity-bearing header from h.
|
|
// Call this before JWT validation re-injects the canonical values. It also
|
|
// unconditionally drops every header whose name starts with "X-Hanzo-" or
|
|
// "X-IAM-" (case-insensitive), closing the "clever-new-prefix" attack vector.
|
|
func StripIdentityHeaders(h http.Header) {
|
|
for _, name := range legacyIdentityHeaders {
|
|
h.Del(name)
|
|
}
|
|
for key := range h {
|
|
upper := strings.ToUpper(key)
|
|
if strings.HasPrefix(upper, "X-IAM-") || strings.HasPrefix(upper, "X-HANZO-") {
|
|
h.Del(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strip is a net/http middleware that calls [StripIdentityHeaders] on every
|
|
// inbound request before delegating to next. Use at the outermost layer of a
|
|
// service, before any JWT middleware that populates the canonical 3 headers.
|
|
func Strip(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
StripIdentityHeaders(r.Header)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|