event: the anonymous lane keeps the events it answers 200 for

Three defects on the one ingest door, each of which returned success while
losing or misfiling the caller's events.

The canonical Event carries its KIND. /v1/event publishes three shapes
(openapi.OneOf{Event, []Event, CaptureBatch}) and they have to mean the same
thing. Event had no `type`, so toCapture left it empty, canonicalType mapped
empty to "event", and "event" is not in publicKinds — so a bare object or bare
array was dropped on the anonymous lane every time, with a 200 receipt, while
the identical event inside {batch:[…]} was admitted. An SDK generated from that
document picking the simplest of the three shapes lost 100% of logged-out
traffic and reported success. Carrying the kind is a WIRE fix, not a capability
one: the allowlist is still the whole anonymous surface.

An unresolvable platform key on the bearer carrier REFUSES. presented() names
the carriers eventTenant consults so the two cannot disagree about what
"presented" means, and they did: ingestKey matches the bearer only for pk- (an
hk-/sk- bearer is IAM's to validate, and widening ingestKey would shadow the
identity path), and projectKey never reads Authorization. So an hk-/sk- bearer
that failed to resolve fell through both onto the anonymous lane — 200, rows
filed under $public, a partition the caller's org cannot read. The same key on
x-api-key already refused. A revoked or mistyped key on the carrier every caller
reaches for first is the likeliest misconfiguration there is.

admission reads the ONE key-prefix authority. Its local copy of
{pk-,sk-,hk-} existed "so admission stays self-contained (no cloud-internal
import)", which was never true — waitlist.go in the same package already imports
cloud. The copy bought nothing and cost a second place to edit.

openapi.yaml and plugin/analytics/openapi.json regenerated from the router.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-07-31 14:30:09 -07:00
parent 1c9160bbad
commit 719e044e34
5 changed files with 205 additions and 12 deletions
+9 -7
View File
@@ -44,6 +44,7 @@ import (
"net/http"
"strings"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
@@ -205,12 +206,13 @@ func bounce(c *zip.Ctx, waitlistURL string) error {
return c.NoContent(http.StatusFound)
}
// apiKeyPrefixes are the Hanzo API-key families: a published key (pk-), a secret
// key (sk-), and hk- (sk- under an older name, retired once IAM renames it). This MIRRORS cloud auth_identity.go APIKeyPrefixes (the ONE
// authority) — kept local so admission stays self-contained (no cloud-internal
// import) while agreeing on the exact contract: a token with one of these
// prefixes is a possession-gated API key, not a session principal.
var apiKeyPrefixes = []string{"pk-", "sk-", "hk-"}
// The Hanzo API-key families a published key (pk-), a secret key (sk-), and hk-
// (sk- under an older name) — are cloud.APIKeyPrefixes, and this package READS that
// list rather than restating it. It used to hold its own copy "so admission stays
// self-contained (no cloud-internal import)", which was never true: waitlist.go in
// this same package already imports cloud. So the copy bought nothing and cost the
// one thing a copy always costs — a second place to edit, with the two agreeing only
// by hand. A key family added to the authority now reaches this gate by construction.
// carriesAPIKey reports whether the request authenticates with a Hanzo API key —
// in the Authorization header (Bearer or Basic-username) or the common api-key /
@@ -233,7 +235,7 @@ func carriesAPIKey(c *zip.Ctx) bool {
}
func hasAPIKeyPrefix(tok string) bool {
for _, p := range apiKeyPrefixes {
for _, p := range cloud.APIKeyPrefixes {
if strings.HasPrefix(tok, p) {
return true
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// anon_wire_integrity_test.go — the two ways the ONE door lost a caller's events
// while answering 200.
//
// Same observable as anon_capability_test.go: 503 ⇒ ADMITTED (reached the write core,
// no warehouse in the harness); 200 {accepted:0,dropped:N} ⇒ the projection refused it;
// 403 ⇒ refused at the gate.
// ── 1. the canonical wire could not say what kind it was ─────────────────────
// TestAnonCanonicalWireCarriesItsKind: /v1/event PUBLISHES three shapes
// (openapi.OneOf{Event, []Event, CaptureBatch}) and they must MEAN the same thing.
// They did not. Event had no `type`, so toCapture left it empty, canonicalType mapped
// empty to "event", and "event" is not in publicKinds — so a bare canonical object or
// array was dropped on the anonymous lane EVERY time, with a 200 receipt, while the
// identical event inside {batch:[…]} was admitted.
//
// That is a document that lies: an SDK generated from it that picks the simplest of
// the three shapes silently loses 100% of logged-out traffic and reports success.
//
// Before the fix the first two subtests answered 200 {accepted:0,dropped:1}.
func TestAnonCanonicalWireCarriesItsKind(t *testing.T) {
roomyRate(t)
app := mountApp(t)
for _, tc := range []struct{ name, body string }{
{"bare object", `{"type":"pageview","event":"$pageview","distinctId":"anon-1","path":"/pricing"}`},
{"bare array", `[{"type":"pageview","event":"$pageview","distinctId":"anon-1","path":"/pricing"}]`},
{"batch envelope", `{"batch":[{"type":"pageview","event":"$pageview","distinctId":"anon-1","path":"/pricing"}]}`},
} {
t.Run(tc.name, func(t *testing.T) {
code, body := doHost(t, app, "/v1/event", "", "", "api.hanzo.ai", tc.body)
if code != http.StatusServiceUnavailable {
t.Fatalf("anonymous pageview on the %s shape = %d (%s), want 503 ADMITTED — "+
"all three published shapes of one wire must mean the same thing, or the "+
"document lies to every generated SDK", tc.name, code, body)
}
})
}
}
// TestAnonCanonicalWireStillCannotWidenItsKind: carrying `type` is a WIRE fix, never a
// capability one. The kind allowlist is still the whole anonymous surface, so the kinds
// that bind an event to a named person or open the custom product/billing surface stay
// dropped on the very shape that just learned to name them.
func TestAnonCanonicalWireStillCannotWidenItsKind(t *testing.T) {
roomyRate(t)
app := mountApp(t)
for _, kind := range []string{"event", "identify", "group"} {
body := `{"type":"` + kind + `","event":"order_completed","distinctId":"attacker",` +
`"revenue":999999,"groupId":"victim-team","personId":"victim-person"}`
code, got := doHost(t, app, "/v1/event", "", "", "api.hanzo.ai", body)
if code == http.StatusServiceUnavailable {
t.Errorf("anonymous kind %q on the bare canonical wire reached the write core — "+
"the wire may now NAME a kind; it may not ADMIT one", kind)
continue
}
if code != http.StatusOK {
t.Errorf("anonymous kind %q = %d (%s), want 200 all-dropped", kind, code, got)
continue
}
if r := receipt(t, got); r.Accepted != 0 || r.Dropped != 1 {
t.Errorf("anonymous kind %q receipt = %+v, want accepted:0 dropped:1", kind, r)
}
}
}
// ── 2. a failed platform key on the bearer carrier was filed under $public ────
// postAuth posts to a door with an Authorization header and nothing else.
func postAuth(t *testing.T, app *zip.App, path, auth, body string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", auth)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test POST %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
// TestUnresolvableAccessKeyBearerRefuses: presented() names the carriers eventTenant
// consults so the two cannot disagree about what "presented" MEANS — and they did.
// ingestKey matches the bearer only for pk- (deliberately: an hk-/sk- bearer is IAM's
// to validate, and widening ingestKey would shadow the identity path). projectKey never
// reads Authorization at all. So an hk-/sk- bearer that FAILED to resolve fell through
// both and took the ANONYMOUS lane: 200, with the caller's rows filed under $public — a
// partition its owner cannot read.
//
// A revoked, rotated or mistyped platform key on the carrier every Hanzo caller reaches
// for FIRST is the most likely misconfiguration there is, and it answered success while
// losing everything. Same key on x-api-key already refused; now the bearer does too.
//
// Before the fix every case here answered 503 (admitted onto the anonymous lane).
func TestUnresolvableAccessKeyBearerRefuses(t *testing.T) {
roomyRate(t)
app := mountApp(t)
body := `{"batch":[{"type":"pageview","distinctId":"anon-1","path":"/pricing"}]}`
for _, key := range []string{"hk-nonexistent-0001", "sk-nonexistent-0001", "pk-nonexistent-0001"} {
for _, door := range doors {
code, got := postAuth(t, app, door.path, "Bearer "+key, body)
if code != http.StatusForbidden {
t.Errorf("POST %s with unresolvable %q = %d (%s), want 403 — a misconfigured "+
"platform key must refuse, never file the caller's events under $public",
door.path, key, code, got)
}
}
}
}
// TestUnidentifiableBearerStillTakesTheAnonymousLaneAfterTheFix is the other half, and
// the reason the fix tests a PREFIX rather than "any bearer". An arbitrary bearer is not
// distinguishable from one minted for another audience — IdentityMiddleware already
// declines to 401 it — so treating its presence as "presented" would 403 every stale or
// foreign token that reaches an ingest door, a refusal on evidence we do not have.
func TestUnidentifiableBearerStillTakesTheAnonymousLaneAfterTheFix(t *testing.T) {
roomyRate(t)
app := mountApp(t)
body := `{"batch":[{"type":"pageview","distinctId":"anon-1","path":"/pricing"}]}`
for _, tok := range []string{"an-opaque-string", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.sig"} {
code, got := postAuth(t, app, "/v1/event", "Bearer "+tok, body)
if code == http.StatusForbidden {
t.Errorf("unidentifiable bearer %q = 403 (%s) — only a bearer carrying a "+
"cloud.APIKeyPrefixes spelling is 'presented'; anything else must degrade", tok, got)
}
}
}
+44 -5
View File
@@ -66,31 +66,42 @@ package analytics
import (
"encoding/json"
"net/http"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/openapi"
"github.com/zap-proto/zip"
)
// Event is the canonical analytics event — the entire ingest contract in four
// Event is the canonical analytics event — the entire ingest contract in five
// fields. Only these are first-class; everything else a caller wants to record
// travels in Properties (the scrubber runs over it downstream, same as every
// event). The tenant is NOT a field: it is resolved server-side from IAM, so a
// caller can only ever write into its OWN org's partition.
type Event struct {
Event string `json:"event"` // event name (required; empty ⇒ dropped as unroutable)
Type string `json:"type"` // canonical kind: pageview | error | identify | group | event (default)
DistinctID string `json:"distinctId"` // the person/visitor id the caller owns
Time string `json:"time"` // optional RFC3339; clamped to server-now on skew/absent
Properties map[string]any `json:"properties"` // everything non-core
}
// toCapture adapts the canonical Event onto the internal CaptureEvent the write
// core consumes. Type is left empty (canonicalType ⇒ "event"); no $-property is
// promoted to a column here — /v1/event stays a strict four-field contract, and
// every non-core field the caller sent stays in Properties.
// core consumes. No $-property is promoted to a column here — every non-core
// field the caller sent stays in Properties.
//
// TYPE IS CARRIED, and it has to be. The kind is what the ANONYMOUS lane admits
// on (publicKinds, public.go): canonicalType maps an empty Type to "event", which
// is NOT allowlisted, so an Event that cannot say "pageview" is dropped — with a
// 200 receipt — every single time. That made two of the three shapes this door
// PUBLISHES (openapi.OneOf{Event, []Event, CaptureBatch}) totally lossy without a
// credential while the third worked, which is a document that lies to any SDK
// generated from it. One wire, three spellings, ONE meaning: whatever CaptureBatch
// can express, the bare object and the bare array express too.
func (e Event) toCapture() CaptureEvent {
return CaptureEvent{
Event: e.Event,
Type: e.Type,
DistinctID: e.DistinctID,
Timestamp: e.Time,
Properties: e.Properties,
@@ -341,8 +352,36 @@ func ingestDecoded(c *zip.Ctx, org, source string, evs []CaptureEvent, dropped i
//
// So: identifiable credential that fails ⇒ 403. Unidentifiable bearer ⇒ the anonymous
// lane, exactly as before this file learned about team tokens.
// WHY bearerAPIKey IS HERE AND ingestKey IS NOT WIDENED. ingestKey returns only a
// pk- so this door never SHADOWS the identity path: an hk-/sk- bearer is IAM's to
// validate, and it arrives here already resolved (tenant ⇒ full capability) or not
// at all. That is right, and it is not the question presented() asks. presented()
// asks whether the caller PRESENTED an identifiable credential, and an hk-/sk-
// bearer is identifiable by the SAME prefix authority every other carrier is judged
// by — so a FAILED one is a misconfiguration and must refuse, exactly as the same
// key refuses today on x-api-key. Without this it took the anonymous lane instead:
// 200, with the caller's rows filed under $public, a partition its owner cannot
// read. That is the precise silent-misfiling failure this function exists to
// prevent, reached through the one carrier every Hanzo caller reaches for first.
func presented(c *zip.Ctx) bool {
return ingestKey(c) != "" || projectKey(c) != "" || teamPresented(c)
return ingestKey(c) != "" || projectKey(c) != "" || bearerAPIKey(c) || teamPresented(c)
}
// bearerAPIKey reports whether Authorization carries an opaque platform key. It
// reads cloud.APIKeyPrefixes — THE authority (auth_identity.go) — rather than
// spelling the prefixes again, so widening the key family cannot leave this
// predicate behind.
func bearerAPIKey(c *zip.Ctx) bool {
tok := teamBearer(c.Header("Authorization"))
if tok == "" {
return false
}
for _, p := range cloud.APIKeyPrefixes {
if strings.HasPrefix(tok, p) {
return true
}
}
return false
}
// handle is THE ingest pipeline and the ONE place in this package where trust level is
+2
View File
@@ -2049,6 +2049,8 @@ components:
type: object
time:
type: string
type:
type: string
type: object
Exception:
properties:
+3
View File
@@ -644,6 +644,9 @@
},
"time": {
"type": "string"
},
"type": {
"type": "string"
}
}
},