Files
zip/ctx.go
zeekayandHanzo Dev 5835826a43 caller: forward the whole identity, and let a background caller state one
identityHeaders forwarded five of the nine headers a gateway asserts, so a
callee reached over the op-call plane saw a principal with no project, no
minted name, no owner and no org-admin bit — while the same handler reached
over REST saw all four. Two of them decide authority: owner carries membership
of a reserved platform org, and isOrgAdmin says only that someone administers
their own org. A subset let a callee read the second for the first, or find
neither, without anything on either side saying so.

Add WithCaller for the caller that has no inbound request to propagate. A
grant issued when an org opens and a meter that debits after the response has
gone out both act FOR a tenant; without a way to say which, the only place left
for the org is the argument, and an org in the argument is one the caller chose.
An inbound request still wins, so this supplies an identity where there is none
and cannot override one. CallerOf reads it back off the same context, so a job
sees what it is about to send.

Ctx gains Project, UserName, UserOwner and IsOrgAdmin, so the REST reader and
the call reader spell one vocabulary.

TestForwardedIdentityCrossesWhole fails on exactly the four dropped fields if
the array is narrowed again; the other three cover the stated path, the two
admin scopes staying apart, and an unattributed call staying anonymous.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 21:28:18 -07:00

253 lines
8.9 KiB
Go

package zip
import (
"bufio"
"context"
"errors"
"io"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/fiber/v3"
)
// Ctx wraps fiber.Ctx and adds the Hanzo identity surface (Org/User/Email
// from gateway-minted X-* headers per HIP-0026), a per-request luxfi/log
// logger, and typed Deps access.
type Ctx struct {
fc fiber.Ctx
app *App
log luxlog.Logger
}
// Fiber returns the underlying fiber.Ctx for one-off escape into Fiber-only APIs.
func (c *Ctx) Fiber() fiber.Ctx { return c.fc }
// App returns the parent App.
func (c *Ctx) App() *App { return c.app }
// Context returns the standard context.Context (deadline + cancellation).
func (c *Ctx) Context() context.Context { return c.fc.Context() }
// Log returns the request-scoped logger. Middleware that adds request_id,
// org, user, etc. via Locals can enrich this by calling SetLog.
func (c *Ctx) Log() luxlog.Logger { return c.log }
// SetLog replaces the request logger (typically by middleware that wants
// to attach request-id / org / user fields).
func (c *Ctx) SetLog(l luxlog.Logger) { c.log = l }
// ----- Hanzo identity (HIP-0026 / gateway X-* headers) ---------------------
// Org returns the X-Org-Id from the JWT-validated gateway. Empty when
// no gateway is in front (local dev / direct ingress).
func (c *Ctx) Org() string { return c.fc.Get(HeaderOrg) }
// Project returns the X-Project-Id from the JWT-validated gateway. It narrows
// the org, and is empty for a request scoped to the org as a whole.
func (c *Ctx) Project() string { return c.fc.Get(HeaderProject) }
// User returns the X-User-Id from the JWT-validated gateway.
func (c *Ctx) User() string { return c.fc.Get(HeaderUser) }
// UserName returns the X-User-Name from the JWT-validated gateway — the minted
// name, where User is the opaque id.
func (c *Ctx) UserName() string { return c.fc.Get(HeaderUserName) }
// UserEmail returns the X-User-Email from the JWT-validated gateway.
func (c *Ctx) UserEmail() string { return c.fc.Get(HeaderUserEmail) }
// UserOwner returns the X-User-Owner gateway claim: the org this principal
// belongs to. A deployment reserving one org for platform operators gates its
// cross-tenant surfaces on this and never on IsOrgAdmin.
func (c *Ctx) UserOwner() string { return c.fc.Get(HeaderUserOwner) }
// IsAdmin returns the X-User-IsAdmin gateway claim as a bool.
func (c *Ctx) IsAdmin() bool { return c.fc.Get(HeaderUserAdmin) == "true" }
// IsOrgAdmin returns the X-User-IsOrgAdmin gateway claim: this principal
// administers their OWN org. It is not platform authority — see UserOwner.
func (c *Ctx) IsOrgAdmin() bool { return c.fc.Get(HeaderUserOrgAdmin) == "true" }
// RequestID returns the value of X-Request-Id (set by the RequestID middleware).
func (c *Ctx) RequestID() string { return c.fc.Get(HeaderRequestID) }
// ----- request basics ------------------------------------------------------
// Method returns the request method.
func (c *Ctx) Method() string { return c.fc.Method() }
// Path returns the request path.
func (c *Ctx) Path() string { return c.fc.Path() }
// Param returns a URL path parameter.
func (c *Ctx) Param(name string) string { return c.fc.Params(name) }
// Query returns a URL query parameter.
func (c *Ctx) Query(name string) string { return c.fc.Query(name) }
// Header returns a request header.
func (c *Ctx) Header(name string) string { return c.fc.Get(name) }
// Host returns the request Host (authority) from the Host header, port included
// when present. It honors X-Forwarded-Host ONLY when the app is configured to
// trust proxies — which zip does NOT do (there is no TrustProxy knob on
// zip.Config), so a client-supplied X-Forwarded-Host is ignored and cannot spoof
// the value. Used for white-label brand-by-host resolution (see
// middleware.ProductionHeaders); keep it un-trusted-proxy so the Server brand
// cannot be forged from a request header.
func (c *Ctx) Host() string { return c.fc.Host() }
// SetHeader sets a response header.
func (c *Ctx) SetHeader(name, value string) { c.fc.Set(name, value) }
// Body returns the raw request body.
func (c *Ctx) Body() []byte { return c.fc.Body() }
// Bind parses the request body into v based on Content-Type (JSON by
// default) and runs struct-tag validation (required/min/max/minlen/maxlen).
// Returns a *HTTPError(400) when either step fails so handlers can
// return the error directly.
func (c *Ctx) Bind(v any) error {
if err := c.fc.Bind().Body(v); err != nil {
return ErrBadRequest("invalid body: " + err.Error())
}
if err := validate(v); err != nil {
return ErrBadRequest(err.Error())
}
return nil
}
// BindQuery parses query parameters into v and runs validation.
func (c *Ctx) BindQuery(v any) error {
if err := c.fc.Bind().Query(v); err != nil {
return ErrBadRequest("invalid query: " + err.Error())
}
if err := validate(v); err != nil {
return ErrBadRequest(err.Error())
}
return nil
}
// BindURI parses URL params into v and runs validation.
func (c *Ctx) BindURI(v any) error {
if err := c.fc.Bind().URI(v); err != nil {
return ErrBadRequest("invalid uri: " + err.Error())
}
if err := validate(v); err != nil {
return ErrBadRequest(err.Error())
}
return nil
}
// ----- response writers ----------------------------------------------------
// Status sets the response status. Chains.
func (c *Ctx) Status(code int) *Ctx { c.fc.Status(code); return c }
// JSON writes the value as JSON with status code.
func (c *Ctx) JSON(code int, v any) error {
c.fc.Status(code)
return c.fc.JSON(v)
}
// String writes a plain-text response.
func (c *Ctx) String(code int, s string) error {
c.fc.Status(code)
return c.fc.SendString(s)
}
// Bytes writes raw bytes.
func (c *Ctx) Bytes(code int, b []byte) error {
c.fc.Status(code)
return c.fc.Send(b)
}
// NoContent writes the status code with no body.
func (c *Ctx) NoContent(code int) error {
c.fc.Status(code)
return nil
}
// SendStream streams data from r to the client (e.g. for SSE).
func (c *Ctx) SendStream(r io.Reader) error {
return c.fc.SendStream(r)
}
// SendStreamWriter writes streaming output via a bufio.Writer (Server-Sent
// Events / chunked transfer). Forwards to fiber.Ctx.SendStreamWriter.
func (c *Ctx) SendStreamWriter(fn func(w *bufio.Writer)) error {
return c.fc.SendStreamWriter(fn)
}
// Locals returns or sets a per-request value.
func (c *Ctx) Locals(key any, value ...any) any {
return c.fc.Locals(key, value...)
}
// Next yields to the next handler in the chain. Use sparingly from
// zip middleware — middleware bodies usually call c.Continue() at the
// end, not Next() mid-handler.
func (c *Ctx) Next() error { return c.fc.Next() }
// Continue is an alias for Next() with the standard middleware idiom.
func (c *Ctx) Continue() error { return c.fc.Next() }
// =============================================================================
// Errors — handlers return one of these to control status code
// =============================================================================
// HTTPError is the canonical error type zip understands. Returning one
// causes the error handler to send a JSON {error, code, status} body.
type HTTPError struct {
Status int `json:"status"`
Code string `json:"code,omitempty"`
Msg string `json:"error"`
}
func (e *HTTPError) Error() string { return e.Msg }
// Errorf builds an HTTPError with the given status and message.
func Errorf(status int, format string, args ...any) *HTTPError {
return &HTTPError{Status: status, Msg: sprintf(format, args...)}
}
// Common shortcuts.
func ErrBadRequest(msg string) *HTTPError { return &HTTPError{Status: 400, Msg: msg} }
func ErrUnauthorized(msg string) *HTTPError { return &HTTPError{Status: 401, Msg: msg} }
func ErrForbidden(msg string) *HTTPError { return &HTTPError{Status: 403, Msg: msg} }
func ErrNotFound(msg string) *HTTPError { return &HTTPError{Status: 404, Msg: msg} }
func ErrConflict(msg string) *HTTPError { return &HTTPError{Status: 409, Msg: msg} }
func ErrInternal(msg string) *HTTPError { return &HTTPError{Status: 500, Msg: msg} }
// errorHandler is the default fiber.ErrorHandler — converts HTTPError
// into a JSON response and falls back to 500 for anything else.
func errorHandler(c fiber.Ctx, err error) error {
var he *HTTPError
if errors.As(err, &he) {
if he.Status == 0 {
he.Status = 500
}
c.Status(he.Status)
return c.JSON(he)
}
var fe *fiber.Error
if errors.As(err, &fe) {
c.Status(fe.Code)
return c.JSON(&HTTPError{Status: fe.Code, Msg: fe.Message})
}
c.Status(500)
return c.JSON(&HTTPError{Status: 500, Msg: err.Error()})
}
// Redirect sends an HTTP redirect to location with the given status code.
func (c *Ctx) Redirect(code int, location string) error {
return c.fc.Redirect().Status(code).To(location)
}
// SetContext replaces the request's context.Context — the boundary idiom: a
// middleware derives a request-scoped context (values, gates, deadlines) ONCE
// and every later c.Context() returns it. One context per request, one setter.
func (c *Ctx) SetContext(ctx context.Context) {
c.fc.SetContext(ctx)
}