mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Base's own registry was already org-native in every method — OrgDB(orgID), HasOrg(orgID), orgDir(orgID) — and only the type names said tenant. So this is the vocabulary catching up with the code, not a change of behaviour. TenantRegistry -> Bases app.Bases().OrgDB(org) TenantConfig -> BasesConfig MultiTenant -> PerOrg it enables per-org databases; say that MULTI_TENANT -> MULTI_BASE core/tenant.go -> core/bases.go Tenant was the wrong noun twice over: it is the multi-tenancy word, and this product's unit is the thing a customer creates per org — a Base. Identity here is org, user, project, and nothing else. The UI never said it; this was Go-side only. 64 packages green. One test was collateral and is fixed rather than renamed: TestToHTTPHeader asserts a pure snake_case-to-Header-Case transform, so rewriting its EXPECTED value while its input stayed "tenant_id" made it assert something false. The case is dropped — org_id already covers that transform.
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 base-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 base 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 base-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 base-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 base-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 base-route middleware chain: Strip → Inject →
|
|
// RequireGateway → next. This is the ONLY approved way to mount a
|
|
// base-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 base 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)
|
|
})
|
|
}
|
|
}
|