Red held this plane on three ship-blockers, all of which were the same shape:
a property the package claimed and did not have.
EVERY READ OF THE SOURCE IS NOW ADMITTED. `lineage` re-ran the exact census a
materialisation is charged 10c for — an exact distinct-count over up to 400 days
of one tenant's feature surface — with no gate, no meter and no bound of any
kind, on a GET any authenticated caller could loop in parallel against the one
stateful store every product on api.hanzo.ai shares. It is not fixed by adding
three checks to that op: `census` and `facts` now take a `scan`, a value with no
exported field that exists only where `admit` returned one, so an unpriced
unbounded warehouse read is not a thing this package can spell. admit is the one
door — it prices at the meter, takes the tenant's single slot and one of the
plane's eight, and lineage additionally runs under the plane's own deadline
rather than the caller's patience. A test reads the package's own AST and fails
if any function that reads the source takes no admission.
A JOB CAN RECORD THAT IT RAN OUT OF TIME. The write that ended a job shared the
context of the work it was reporting on, so the one case a refusal exists for —
the fifteen-minute wall expiring — could never be written: the driver refused
before sending anything and the version sat in `materializing` with an EMPTY
refusal forever. The work's context and the record's are now two, and `record`
opens its own, so they cannot be fused again.
ONE MINT. hanzo.risk_feature.org is written by the rollup's `qualify`, which
lower-cases the brand and requires a registered one, and was read here by a mint
that only trimmed and accepted anything. CLOUD_BRAND=Hanzo therefore had the
writer filing rows under `hanzo/acme` while this plane asked for `Hanzo/acme`
and got nothing, forever, with no error anywhere. The mint canonicalises and
requires a brand the registry vouches for; a test pins it byte-for-byte against
the writer's algorithm over every brand and spelling, and Mount refuses at boot
rather than 403-ing every request.
Also closed, from the same review:
the biller's own words A gate that cannot be ASKED fails with the peer's
transport detail, which zip renders as the body of a
500. Both priced ops now refuse through cloud.Denied
— the fleet's one 402/503 contract — and the reason
goes to the log.
version numbers Disposal dropped the register, so the counter reset:
a second `orders` reached v3 again with different
bytes and nothing recorded that the first v3 existed.
Disposal now drops the BYTES and marks the register,
`disposed` outranking `ready` in the engine's own
version column, so numbering is monotone across a
disposal and "prove you deleted it" is answered by a
record instead of by silence.
a falsifiable claim lineage certified a window re-derivable whenever the
source had not SHRUNK — but the source is fed by a
rollup running behind the events, so holding MORE is
the ordinary case and re-running the spec would not
reproduce the digest. Reproducible now means exact
agreement on all four recorded measurements.
one brand's org cloud trusts every white-label brand's issuer, so a
lux.id token reached a hanzo deployment and minted
`hanzo/<org>` for it. The identity boundary now
stamps the brand it VERIFIED from `iss`, and the
mint refuses when the two facts disagree.
what is per process The package claimed two processes over one store
answer identically. Reads, declarations and disposals
do; ADMISSION does not, and that is now stated with
the deployment contract it implies rather than
claimed.
And one the review missed: the branch already failed the fleet's own
TestRequestEscapeHatchIsPinned, because `who` reads the request for the BILLING
identity — the ledger, the validated project, the attribution — which is
deliberately a different value from the tenant. The reason is now on the record
in allowedRequestUses, where the next reader will find it.
Every fix carries a regression test, and each was mutation-tested: with the fix
reverted, its test fails.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
136 lines
6.7 KiB
Go
136 lines
6.7 KiB
Go
package cloud
|
|
|
|
// typed.go is the seam between a REQUEST and a TYPED op. ZipApp (scope.go) gets a
|
|
// subsystem to the registry; this gets each request across it.
|
|
//
|
|
// zip.Get[In,Out] is the ONE registration every projection reads — REST,
|
|
// OpenAPI, MCP and the CLI all derive from that single registry entry, which is
|
|
// exactly why a route that is NOT a typed op is invisible to all four. But a
|
|
// typed handler receives only a context.Context and its decoded In, so three
|
|
// facts a cloud handler needs are not in its hands:
|
|
//
|
|
// - the VALIDATED org, and beside it the bare fact that the caller was
|
|
// VALIDATED AT ALL — the two facts a gate turns on, since a plane with no
|
|
// org-scoped rows (engine's shared runtime) authenticates without a tenant.
|
|
// Both live in headers the typed handler cannot see, and neither may EVER
|
|
// become an In field: an In field is caller-supplied, so a tenant key read
|
|
// from one is a cross-tenant read the caller asserted for itself.
|
|
// - the REQUEST itself, for a subsystem that FORWARDS the caller's identity
|
|
// instead of only reading it. A tenant-scoped proxy (clients/visor) passes
|
|
// the caller's own identity headers — and, where no service credential is
|
|
// configured, their bearer — to the upstream that owns the resource, so an
|
|
// op that cannot reach the request drops the caller's identity on the far
|
|
// side of the hop. Reaching it is not an escape from "typed": the request is
|
|
// the same value the raw handler beside it holds, and an op that wants only
|
|
// its tenant asks principal.OrgFrom and never sees a header.
|
|
// - the response status. zip writes 200 (204 for a nil Out) and has no
|
|
// vocabulary for 201 Created or 202 Accepted, so a route converted without
|
|
// one silently downgrades its status — a wire break for every client that
|
|
// checks it.
|
|
//
|
|
// All three are the same shape of problem — a per-request VALUE the typed
|
|
// signature drops — so they share ONE middleware, installed where it precedes
|
|
// the typed routes it serves: fiber runs middleware in registration order, so
|
|
// one installed after its leaves never runs. Serve installs it once for the
|
|
// whole binary, right after the identity boundary that makes the org
|
|
// trustworthy; a subsystem whose routes all sit under a prefix it owns may
|
|
// install it on that group instead. Nesting is harmless — the inner one is the
|
|
// one the handler sees, and the outer finds nothing to apply.
|
|
//
|
|
// FAIL CLOSED OFF THE HTTP PATH. The CLI projection's LocalInvoke runs an op
|
|
// with no request at all, so Request, principal.OrgFrom and principal.ValidatedFrom
|
|
// all read nothing there and a gated op refuses — the handler's own 403 gate, with
|
|
// no second gate to keep in sync. An MCP tools/call at POST /mcp is an ordinary
|
|
// HTTP request and does carry them, so it is gated exactly like the REST route
|
|
// it mirrors: a validated principal is served, an anonymous one is refused.
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/hanzoai/cloud/apps/principal"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// bound is everything Bridge carries for one request: the request itself, and
|
|
// the slot an op writes a non-default status into. ONE value under one key, so
|
|
// the middleware costs one allocation however many facts have to cross.
|
|
type bound struct {
|
|
req *zip.Ctx
|
|
status int
|
|
}
|
|
|
|
// boundKey names the request-scoped slot. Unexported zero-size type:
|
|
// unforgeable from another package.
|
|
type boundKey struct{}
|
|
|
|
// Bridge carries into a typed op the request facts its signature drops, and
|
|
// carries back out the one fact it cannot state. Install it BEFORE the typed
|
|
// routes it serves — fiber runs middleware in registration order, so one
|
|
// installed after its leaves never runs — and after the identity boundary, so
|
|
// the identity it parks is the validated one.
|
|
//
|
|
// It parks the TWO facts a gate turns on, in ONE expression, so they are always
|
|
// set together and can never disagree: the validated ORG (principal.WithOrg, for
|
|
// a plane with rows to scope) and VALIDATED-NESS itself (principal.WithValidated,
|
|
// for a plane whose reads are deployment-global and whose gate is therefore
|
|
// authentication). Both are read back through principal, so a gate that needs
|
|
// either does not reach for the request.
|
|
func Bridge() zip.Handler {
|
|
return func(c *zip.Ctx) error {
|
|
b := &bound{req: c}
|
|
ctx := principal.WithBrand(principal.WithValidated(principal.WithOrg(c.Context(), c), c), c)
|
|
c.SetContext(context.WithValue(ctx, boundKey{}, b))
|
|
err := c.Continue()
|
|
// Success only: an error already carries its own status. fasthttp writes
|
|
// the response after the whole chain returns, so setting it here still
|
|
// lands on the wire.
|
|
if err == nil && b.status != 0 {
|
|
c.Status(b.status)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Request returns the request a typed op is serving, for the ops that must
|
|
// FORWARD the caller's identity rather than merely read it (see the package
|
|
// note). Absent off the HTTP path, where the honest answer is that there is no
|
|
// request — a caller that needs one refuses rather than inventing an identity.
|
|
func Request(ctx context.Context) (*zip.Ctx, bool) {
|
|
b, ok := ctx.Value(boundKey{}).(*bound)
|
|
if !ok || b.req == nil {
|
|
return nil, false
|
|
}
|
|
return b.req, true
|
|
}
|
|
|
|
// Created marks the response 201 Created and Accepted marks it 202 Accepted.
|
|
//
|
|
// Deprecated: declare the status on the op instead — `zip.WithStatus(201)` —
|
|
// which is the same fact in the one place every projection reads.
|
|
//
|
|
// These exist because zip once had no vocabulary for a success status other than
|
|
// 200 and 204, so the only way to answer 201 was to set it per request from
|
|
// inside the handler. That works on the wire and nowhere else: the status is a
|
|
// CONTRACT detail, and setting it here writes it into a side channel no
|
|
// projection can read. The document keeps saying 200, so does every SDK
|
|
// generated from it, and the route has always sent 201. It is the same failure
|
|
// as a query parameter's required-ness being invisible — a contract detail that
|
|
// exists only at run time is not a contract.
|
|
//
|
|
// zip v1.18.2 closed the gap: `zip.Post(app, path, fn, zip.WithStatus(201))`
|
|
// keys the document's response object on 201, so a generated client expects what
|
|
// the service sends. New ops declare it; these two stay so the call sites that
|
|
// predate it keep working while they are converted, and they still set the
|
|
// status they always did.
|
|
//
|
|
// Both are a no-op off the HTTP path, where there is no status to set.
|
|
func Created(ctx context.Context) { setStatus(ctx, http.StatusCreated) }
|
|
func Accepted(ctx context.Context) { setStatus(ctx, http.StatusAccepted) }
|
|
|
|
func setStatus(ctx context.Context, code int) {
|
|
if b, ok := ctx.Value(boundKey{}).(*bound); ok {
|
|
b.status = code
|
|
}
|
|
}
|