From 7756e11ac46e9283d770617b9a69c37edb0e02fe Mon Sep 17 00:00:00 2001 From: taitelee Date: Tue, 7 Jul 2026 21:07:10 -0400 Subject: [PATCH 01/27] fix(stream): apply policy row-filter per subscriber on SSE --- CHANGELOG.md | 1 + cmd/wavehouse/main.go | 2 +- docs/src/content/docs/access-control.mdx | 4 +- docs/src/content/docs/architecture.md | 2 +- internal/api/errors_test.go | 2 +- internal/api/router_test.go | 14 +- internal/api/stream.go | 12 +- internal/api/stream_test.go | 8 +- internal/discovery/validation.go | 21 ++- internal/discovery/validation_test.go | 29 ++++ internal/policy/policy.go | 111 +++++++++++----- internal/policy/rowfilter.go | 121 +++++++++++++++++ internal/policy/rowfilter_test.go | 107 +++++++++++++++ internal/stream/hub.go | 160 ++++++++++++++++------- internal/stream/hub_test.go | 157 ++++++++++++++++++++-- internal/stream/subscriber.go | 13 ++ tests/integration/setup_test.go | 2 +- 17 files changed, 654 insertions(+), 112 deletions(-) create mode 100644 internal/policy/rowfilter.go create mode 100644 internal/policy/rowfilter_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1ab641..faf59ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/discovery/validation_test.go`, `internal/stream/hub_test.go`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). Equality and set predicates (`_eq`/`_neq`/`_in` — the tenant/user scoping row-security actually uses) are enforced exactly; ordering predicates (`_gt`/`_lt`) are schema-informed — numeric columns compare numerically (matching ClickHouse), other types lexicographically — which is best-effort for exotic types stored as numbers (a documented boundary in access-control.mdx), with every ambiguous or uncomparable value failing closed (the row is withheld). Replay (gap-fill) applies the same per-connection row check. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant noted in the #294/#353 Changed entry below. - **Policy `_in` is now enforced on both the row-`filter` and insert-`check` paths, closing a fail-open row-security gap** (`internal/policy/policy.go`, `internal/api/ingest.go`, `docs/src/content/docs/access-control.mdx`, plus tests in `internal/policy/policy_test.go`, `internal/api/ingest_test.go`): closes #224. The `Filter` schema accepted `_in` but the engine never read it: on the row-`filter`/SELECT path `resolveFilters` had no `_in` branch, so a row-security filter like `tenant_id: { _in: … }` produced **no `WHERE` predicate** and the role saw every row instead of its tenant subset (a fail-open, same family as #223); on the `check`/INSERT path only `_eq` was honored, silently dropping any other operator. `_in` now takes a single claim that resolves to a JSON **array** (the multi-tenant case — a token's `tenant_ids` list) and emits `col IN (?, …)` with one bound param per element; a scalar claim is a one-element set, and an empty/absent claim matches **no rows** (fail-closed) rather than widening to all of them. On the insert path an `_in` check requires the column be present and one of the set — there is no single value to auto-inject as `_eq` does, so an omitted column is rejected (`403 check failed`). The comparison operators are enforced on `filter` (`_eq`/`_neq`/`_gt`/`_lt`/`_in` all produce predicates now, so nothing is rejected there) and, on `check`, `_neq`/`_gt`/`_lt` become a loud config-load rejection (no insert-time semantics; `check` honors `_eq` + `_in`). The `_in` value stays a single templated string in the wire schema (Go `Filter.In`, SDK `PolicyFilter._in`), matching the established "set = array" shape of the caller-query `in` operator. - **`denied_aggregations` is now enforced case-insensitively against the caller-supplied function name, closing a policy bypass** (`internal/policy/policy.go`, plus tests in `internal/policy/policy_test.go`): closes #318. `IsAggregationAllowed` lower-cased the aggregation name only *after* the deny-list loop and the empty-allow-list early return, so the deny check compared a lower-cased deny entry against the raw caller input — a denied aggregation slipped past simply by changing case (`SUM` bypassed a `sum` deny entry, and with an empty allow list the call was then permitted). `isValidAggFn` already accepts any casing and the SQL builder emits the function name verbatim, so the denied aggregation actually executed. The case fold now happens once, before the deny check, so deny wins regardless of caller casing — matching the case-insensitive contract the access-control docs already specified. - **A role's structured-query resource caps are now enforced server-side by ClickHouse, so a public read can't outrun its budget during a server-side scan/merge/aggregation phase** (`internal/api/ch_settings.go` (new), `internal/api/structured_query.go`, `internal/policy/policy.go`, `internal/policy/scalars.go` (new), `internal/config/config.go`, `internal/query/builder.go`, `cmd/wavehouse/main.go`, `config.yaml`, `clients/ts/src/types.ts`, `docs/src/content/docs/{access-control.mdx,configuration.mdx}`, plus tests in `internal/api/ch_settings_test.go`, `internal/policy/{policy,scalars}_test.go`, `internal/config/config_test.go`, `internal/query/builder_test.go`, `tests/integration/query_limits_test.go` (all new/expanded), `tests/e2e/sdk/query.test.ts`): closes #316. The native ClickHouse connection passed **no per-query `Settings`**, so a read's policy caps bound it only client-side — a Go `context` deadline (which cancels only while the client is reading result blocks) plus a SQL `LIMIT`. Memory and rows scanned were **never bounded server-side**, so a heavy aggregation could allocate gigabytes of state or scan an entire table well within the time budget; and because `clickhouse-go` derives a `max_execution_time` setting from the context deadline only for deadlines `> 1s`, a sub-second time cap reached ClickHouse with no server-side time bound at all. The structured-query path now attaches per-query `Settings` derived from the role's resolved permissions: `max_execution_time` (fractional seconds, emitted explicitly so the sub-second case is enforced), `max_result_rows` + `result_overflow_mode=throw` (defense-in-depth behind the SQL `LIMIT`), `max_rows_to_read` + `read_overflow_mode=throw`, and `max_memory_usage` — so a query that exceeds its budget is rejected by the server (ClickHouse codes 158 / 241) rather than running to completion. **Boundary:** WaveHouse owns the *dynamic, per-role* caps (sent as per-query settings); the *global, static* backstop — which applies to every query including named pipes and raw admin SQL — is configured in **ClickHouse's own settings profiles and quotas** (documented in `configuration.mdx`), composes with the per-role caps, and holds even against a WaveHouse bug. **Schema (pre-launch):** the per-role policy fields are human-readable in / numeric out — `max_execution_time_ms` (int) → **`max_execution_time`** (set as a duration string `"5s"` or a bare ms number; read back as ms), the new **`max_memory_usage`** (set as a size string `"4GiB"` — IEC/SI respected via `dustin/go-humanize`, so `4GB` ≠ `4GiB` — or a bare byte number; read back as bytes), and the new **`max_rows_to_read`** (int), backed by two small `Millis`/`ByteSize` types in the `policy` package. The formerly hard-coded `query.DefaultMaxRows = 10000` result-LIMIT becomes the documented, tunable `query.default_max_rows` config knob (`Build` takes it as a parameter). Raw admin SQL remains unbounded by WaveHouse (governed by ClickHouse). Verified RED before the fix: the handler-level integration test confirmed a capped read returned the full result set when the settings weren't sent. diff --git a/cmd/wavehouse/main.go b/cmd/wavehouse/main.go index c75eb853..7ae3e21a 100644 --- a/cmd/wavehouse/main.go +++ b/cmd/wavehouse/main.go @@ -292,7 +292,7 @@ func run() int { // handler (write counts), and the Hub that projects/serializes each event once // per (topic, role) and pushes it to that role's subscribers. sseMetrics := stream.NewMetrics() - streamHub := stream.NewHub(policyStore, sseMetrics) + streamHub := stream.NewHub(policyStore, registry, sseMetrics) // Start policy watch for cluster-wide updates. go policyStore.Watch(ctx) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index dda1e0b5..13664a15 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -374,8 +374,8 @@ The same policy drives every data path, but not every field is meaningful on eve | Raw SQL | `POST /v1/admin/query` | `admin_role` only — no per-statement policy; the role gate is the entire authorization story | | Named pipe | `GET/POST /v1/pipes/{name}` | per-pipe `allowed_roles` (not the policy engine; see [Named Pipes](/pipes)). Resource limits come from ClickHouse's [server-wide settings](/configuration#server-side-resource-limits), not per-role policy caps | -:::caution[Live streams enforce access, not row filters] -SSE subscribers are checked for table-level `select` permission and have denied columns stripped from each event, but the row-level `filter` predicates and the resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) are a property of the SQL query path and are **not** applied to the live event stream. If a role must never observe another tenant's rows in real time, don't grant it stream access to a shared table — scope the data at the table level. +:::caution[Live streams enforce column and row policy, but not resource limits] +SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Equality and set predicates (`_eq`, `_neq`, `_in`) — the usual tenant/user scoping — are enforced exactly. Ordering predicates (`_gt`, `_lt`) are best-effort: the stream compares numeric columns numerically (matching ClickHouse) but lacks ClickHouse's full per-type coercion, so an ordering filter on an exotic type (a `Decimal`/`Int128` beyond `float64` precision, or a `DateTime` ingested as a Unix number) can admit or withhold a row the query path wouldn't. The resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) remain a property of the SQL query path and are **not** applied to the live event stream; if those caps are part of a role's isolation story, don't rely on them over the stream. ::: ## Managing the policy diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index 4c6a8958..f35d5e80 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -85,7 +85,7 @@ The API layer uses [Chi](https://github.com/go-chi/chi) for routing with Request The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)) lives next to the keepalive primitives it shares. One abstraction per file. -- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The `(topic, role)` key is sufficient because column visibility derives only from the role+table policy entry, never from JWT claims (claims feed only the row-level `WHERE`/`CHECK`, which the stream path does not apply). `ReplayFrame` shares the same projection for the handler's per-connection gap-fill. +- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the numeric-aware comparison seeded from the schema registry). This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayFrame` shares the same projection and per-connection row check for the handler's gap-fill. - **subscriber.go** — `Subscriber`, the per-connection handle. It owns a single ready-to-write outbound queue of `Frame`s (each tagged with its `kind`, so the handler labels the write where it happens): producers — the keepalive wheel and the event `Hub` — fan frames in with `Send` (non-blocking; a full queue drops and the `Hub` counts it), and the handler drains `Frames()` to the client verbatim. The queue is sized for buffering live events (cap 64, up from the keepalive-only cap 1; #152 will make it a knob), and an `Evicted()` channel is the seam the slow-consumer follow-up closes to disconnect a wedged consumer. - **bucket.go** — `Bucket`, the reusable fan-out primitive: a concurrency-safe set of subscribers. `Push` delivers a shared `Frame` to each fire-and-forget (the keepalive wheel's ring); `Snapshot` exposes the members so the event `Hub` can fan out while inspecting each `Send` result (to count drops). The `Hub` holds one `Bucket` per `(topic, role)` so a projected frame is built once and sent to every member instead of re-projected per subscriber. - **heartbeat.go** — The keepalive wheel (`Heartbeater`). A single process-wide ticker fans a minimal `:` comment across the ring of `Bucket`s, waking ~1/N of live streams per tick so the writes don't synchronize. The effective per-connection keepalive period is `stream.keepalive_interval` (the wheel ticks every `keepalive_interval ÷ keepalive_buckets`, so one rotation spans the interval); the owning handler goroutine does the actual write, so the shared ticker never touches a `ResponseWriter` directly. diff --git a/internal/api/errors_test.go b/internal/api/errors_test.go index 358a3f34..3f2956e7 100644 --- a/internal/api/errors_test.go +++ b/internal/api/errors_test.go @@ -194,7 +194,7 @@ func TestAuthzDenied_LogsChiRoutePattern(t *testing.T) { router := NewRouter(Dependencies{ Ingest: NewIngestHandler(reg, &testutil.MockPublisher{}, logger), Query: &QueryHandler{}, - SSE: NewStreamHandler(stream.NewHub(nil, nil), nil), + SSE: NewStreamHandler(stream.NewHub(nil, nil, nil), nil), Health: &HealthHandler{}, Schema: NewSchemaHandler(reg), AuthMW: func(next http.Handler) http.Handler { return next }, diff --git a/internal/api/router_test.go b/internal/api/router_test.go index bf9dcaac..a0c1d5ac 100644 --- a/internal/api/router_test.go +++ b/internal/api/router_test.go @@ -283,7 +283,7 @@ func TestNewRouter_RoutesRegistered(t *testing.T) { {Name: "events", Columns: []discovery.Column{{Name: "id", Type: "String"}}}, }) pub := &testutil.MockPublisher{} - hub := stream.NewHub(nil, nil) + hub := stream.NewHub(nil, nil, nil) deps := Dependencies{ Ingest: NewIngestHandler(reg, pub, testutil.NopLogger()), @@ -339,7 +339,7 @@ func TestNewRouter_RoutesRegistered(t *testing.T) { func TestNewRouter_CORSOnStream(t *testing.T) { t.Parallel() - hub := stream.NewHub(nil, nil) + hub := stream.NewHub(nil, nil, nil) router := NewRouter(Dependencies{ SSE: NewStreamHandler(hub, nil), Health: &HealthHandler{}, @@ -411,7 +411,7 @@ func TestNewRouter_RawSQLAdminGate(t *testing.T) { reg := discovery.NewSchemaRegistryFromMap(nil) pub := &testutil.MockPublisher{} - hub := stream.NewHub(nil, nil) + hub := stream.NewHub(nil, nil, nil) router := NewRouter(Dependencies{ Ingest: NewIngestHandler(reg, pub, testutil.NopLogger()), @@ -472,7 +472,7 @@ func TestNewRouter_OptionalDepsNil(t *testing.T) { reg := discovery.NewSchemaRegistryFromMap(nil) pub := &testutil.MockPublisher{} - hub := stream.NewHub(nil, nil) + hub := stream.NewHub(nil, nil, nil) deps := Dependencies{ Ingest: NewIngestHandler(reg, pub, testutil.NopLogger()), @@ -523,7 +523,7 @@ func TestNewRouter_NotFoundEmitsJSON(t *testing.T) { reg := discovery.NewSchemaRegistryFromMap(nil) pub := &testutil.MockPublisher{} - hub := stream.NewHub(nil, nil) + hub := stream.NewHub(nil, nil, nil) deps := Dependencies{ Ingest: NewIngestHandler(reg, pub, testutil.NopLogger()), Query: &QueryHandler{}, @@ -548,7 +548,7 @@ func TestNewRouter_MethodNotAllowedEmitsJSON(t *testing.T) { reg := discovery.NewSchemaRegistryFromMap(nil) pub := &testutil.MockPublisher{} - hub := stream.NewHub(nil, nil) + hub := stream.NewHub(nil, nil, nil) deps := Dependencies{ Ingest: NewIngestHandler(reg, pub, testutil.NopLogger()), Query: &QueryHandler{}, @@ -645,7 +645,7 @@ func TestNewRouter_SchemaAdminOnly(t *testing.T) { t.Parallel() reg := discovery.NewSchemaRegistryFromMap(nil) pub := &testutil.MockPublisher{} - hub := stream.NewHub(nil, nil) + hub := stream.NewHub(nil, nil, nil) router := NewRouter(Dependencies{ Ingest: NewIngestHandler(reg, pub, testutil.NopLogger()), diff --git a/internal/api/stream.go b/internal/api/stream.go index 44485224..a0727573 100644 --- a/internal/api/stream.go +++ b/internal/api/stream.go @@ -42,10 +42,13 @@ func (h *StreamHandler) Handle(w http.ResponseWriter, r *http.Request) { } // Resolve stream permissions for this request. The raw role from context is the - // bucket key: the Hub projects/serializes once per (topic, role), and column - // visibility derives only from the role+table policy entry — never from claims - // (see stream.Hub). Evaluate maps an empty role to the policy default_role. + // bucket key: the Hub serializes the column projection once per (topic, role), + // since column visibility derives only from the role+table policy entry. Claims + // are separate — they drive the row-level-security filter, which the Hub evaluates + // per subscriber (see stream.Hub), so they ride on the Subscriber rather than the + // bucket key. Evaluate maps an empty role to the policy default_role. role := auth.RoleFromContext(r.Context()) + claims, _ := auth.ClaimsFromContext(r.Context()) // TODO: impl scope scope := "" @@ -79,6 +82,7 @@ func (h *StreamHandler) Handle(w http.ResponseWriter, r *http.Request) { // buffer in the subscriber queue instead of being missed (an overlap with // replay yields duplicates, deduped client-side by id: — at-least-once). sub := stream.NewSubscriber() + sub.SetClaims(claims) h.Hub.Add(topic, role, sub) defer h.Hub.Remove(topic, role, sub) @@ -96,7 +100,7 @@ func (h *StreamHandler) Handle(w http.ResponseWriter, r *http.Request) { // the replayed frame. A write error means the client is gone, so stop the // gap-fill and let the deferred cleanup unwind. sendReplay := func(data []byte) bool { - f, ok := h.Hub.ReplayFrame(role, data) + f, ok := h.Hub.ReplayFrame(role, claims, data) if !ok { return true // filtered for this role — skip } diff --git a/internal/api/stream_test.go b/internal/api/stream_test.go index 5d67b763..88db1d99 100644 --- a/internal/api/stream_test.go +++ b/internal/api/stream_test.go @@ -22,7 +22,7 @@ import ( func TestSSE_RejectsMissingOrInvalidTable(t *testing.T) { t.Parallel() - h := &StreamHandler{Hub: stream.NewHub(nil, nil)} + h := &StreamHandler{Hub: stream.NewHub(nil, nil, nil)} cases := []struct { name string @@ -50,7 +50,7 @@ func TestSSE_RejectsMissingOrInvalidTable(t *testing.T) { func TestSSE_AcceptsSafeTableName(t *testing.T) { t.Parallel() - h := &StreamHandler{Hub: stream.NewHub(nil, nil)} + h := &StreamHandler{Hub: stream.NewHub(nil, nil, nil)} // Use a request context that's already cancelled so the handler exits // the live-stream select loop immediately instead of blocking the test. @@ -75,7 +75,7 @@ func TestSSE_EmitsHeartbeatsWhenIdle(t *testing.T) { hb := stream.NewHeartbeater(20*time.Millisecond, 1) go hb.Run(t.Context()) - h := &StreamHandler{Hub: stream.NewHub(nil, nil), Heartbeater: hb} + h := &StreamHandler{Hub: stream.NewHub(nil, nil, nil), Heartbeater: hb} ctx, cancel := context.WithCancel(context.Background()) req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/v1/stream?table=clicks", nil) @@ -114,7 +114,7 @@ func TestSSE_WheelTickRacesHandlerTeardown(t *testing.T) { defer cancel() go hb.Run(ctx) - h := &StreamHandler{Hub: stream.NewHub(nil, nil), Heartbeater: hb} + h := &StreamHandler{Hub: stream.NewHub(nil, nil, nil), Heartbeater: hb} const conns = 40 var wg sync.WaitGroup diff --git a/internal/discovery/validation.go b/internal/discovery/validation.go index 90af29be..b2dbdf51 100644 --- a/internal/discovery/validation.go +++ b/internal/discovery/validation.go @@ -48,9 +48,9 @@ func Validate(schema *TableSchema, data map[string]any) error { return nil } -// isTypeCompatible checks whether a Go/JSON value can be stored in the given ClickHouse type. -func isTypeCompatible(chType string, val any) bool { - // Robustly unwrap nested modifiers (e.g., LowCardinality(Nullable(String))) +// unwrapType strips Nullable(...) and LowCardinality(...) modifiers (nested in any +// order, e.g. LowCardinality(Nullable(String))) down to the base ClickHouse type. +func unwrapType(chType string) string { for { if strings.HasPrefix(chType, "Nullable(") && strings.HasSuffix(chType, ")") { chType = chType[9 : len(chType)-1] @@ -60,8 +60,13 @@ func isTypeCompatible(chType string, val any) bool { chType = chType[15 : len(chType)-1] continue } - break + return chType } +} + +// isTypeCompatible checks whether a Go/JSON value can be stored in the given ClickHouse type. +func isTypeCompatible(chType string, val any) bool { + chType = unwrapType(chType) switch { // String-compatible types accept Strings, Numbers (coerced), and Bools @@ -144,6 +149,14 @@ func isTypeCompatible(chType string, val any) bool { } } +// IsNumericType reports whether chType is a ClickHouse numeric type (integer, float, +// or decimal), unwrapping Nullable/LowCardinality modifiers first. The stream +// row-filter evaluator uses it to choose numeric vs lexicographic comparison for a +// column, so ordering predicates (>, <) on numbers match ClickHouse (9 < 100). +func IsNumericType(chType string) bool { + return isNumericType(unwrapType(chType)) +} + // isNumericType returns true for ClickHouse integer, float, and decimal types. func isNumericType(chType string) bool { switch { diff --git a/internal/discovery/validation_test.go b/internal/discovery/validation_test.go index 1dbbb08a..4a4015d1 100644 --- a/internal/discovery/validation_test.go +++ b/internal/discovery/validation_test.go @@ -224,3 +224,32 @@ func TestIsNumericType(t *testing.T) { }) } } + +// TestIsNumericType_Exported checks the exported wrapper the stream row-filter uses: +// it must unwrap Nullable/LowCardinality (in any nesting) before classifying, so a +// Nullable(UInt64) column still compares numerically. +func TestIsNumericType_Exported(t *testing.T) { + t.Parallel() + + tests := []struct { + chType string + want bool + }{ + {"UInt64", true}, + {"Nullable(UInt64)", true}, + {"LowCardinality(Int32)", true}, + {"LowCardinality(Nullable(Float64))", true}, + {"Decimal(10,2)", true}, + {"String", false}, + {"Nullable(String)", false}, + {"LowCardinality(String)", false}, + {"DateTime", false}, + } + + for _, tt := range tests { + t.Run(tt.chType, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, IsNumericType(tt.chType)) + }) + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 198bc5a3..c0f5a4f3 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -62,11 +62,16 @@ type Filter struct { // ResolvedPermissions is the result of evaluating a policy against JWT claims. type ResolvedPermissions struct { - Allowed bool - AllowColumns []string - DenyColumns []string - WhereClause string - WhereParams []any + Allowed bool + AllowColumns []string + DenyColumns []string + WhereClause string + WhereParams []any + // rowFilter is the same row-level-security predicate as WhereClause/WhereParams, + // kept in resolved form so the stream path can evaluate it in memory (RowVisible) + // while the query path renders it to SQL. Both derive from one resolvePredicates + // call in Evaluate, so the two read surfaces can't drift. See rowfilter.go. + rowFilter []resolvedPredicate CheckClauses map[string]any // column → required value (for inserts) AllowedAggregations []string DeniedAggregations []string @@ -189,7 +194,13 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * return &ResolvedPermissions{Allowed: false} } } - clauses, params := resolveFilters(perms.Filter, claims) + // Resolve the row-filter once into predicates, then render both read surfaces + // from that single source so they can't drift: the query path binds them into + // a SQL WHERE here; the stream path evaluates the same predicates in memory + // (ResolvedPermissions.RowVisible). + preds := resolvePredicates(perms.Filter, claims) + resolved.rowFilter = preds + clauses, params := predicatesToSQL(preds) if len(clauses) > 0 { resolved.WhereClause = strings.Join(clauses, " AND ") resolved.WhereParams = params @@ -214,52 +225,92 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * return resolved } -// resolveFilters converts filter definitions with claim templates into SQL WHERE clauses. -func resolveFilters(filters map[string]Filter, claims map[string]any) ([]string, []any) { - var clauses []string - var params []any +// resolvedPredicate is one row-filter comparison with its claim templates already +// resolved to concrete string values — the shared, render-agnostic form the query +// path turns into SQL (predicatesToSQL) and the stream path evaluates in memory +// (RowVisible). Op is one of "=", "!=", ">", "<", "in". Values holds one element +// for the scalar operators and zero-or-more for "in" (empty ⇒ matches no rows). +type resolvedPredicate struct { + Column string + Op string + Values []string +} + +// resolvePredicates resolves each filter's claim templates once into predicates. +// Both read surfaces derive from this single result so they can't drift; the +// operator order within a column (=, !=, >, <, in) mirrors the former inline SQL. +func resolvePredicates(filters map[string]Filter, claims map[string]any) []resolvedPredicate { + var preds []resolvedPredicate for col, f := range filters { - // Quote the policy-authored column the same way the query builder quotes - // caller columns, so a row-filter on a weird-but-legal column name (dots, - // spaces, keywords) is emitted safely. - qcol := chsql.QuoteIdent(col) if f.Eq != nil { - val := resolveTemplate(*f.Eq, claims) - clauses = append(clauses, fmt.Sprintf("%s = ?", qcol)) - params = append(params, val) + preds = append(preds, resolvedPredicate{col, "=", []string{resolveTemplate(*f.Eq, claims)}}) } if f.Neq != nil { - val := resolveTemplate(*f.Neq, claims) - clauses = append(clauses, fmt.Sprintf("%s != ?", qcol)) - params = append(params, val) + preds = append(preds, resolvedPredicate{col, "!=", []string{resolveTemplate(*f.Neq, claims)}}) } if f.Gt != nil { - val := resolveTemplate(*f.Gt, claims) - clauses = append(clauses, fmt.Sprintf("%s > ?", qcol)) - params = append(params, val) + preds = append(preds, resolvedPredicate{col, ">", []string{resolveTemplate(*f.Gt, claims)}}) } if f.Lt != nil { - val := resolveTemplate(*f.Lt, claims) - clauses = append(clauses, fmt.Sprintf("%s < ?", qcol)) - params = append(params, val) + preds = append(preds, resolvedPredicate{col, "<", []string{resolveTemplate(*f.Lt, claims)}}) } if f.In != nil { - vals := resolveInValues(*f.In, claims) - if len(vals) == 0 { + preds = append(preds, resolvedPredicate{col, "in", toStrings(resolveInValues(*f.In, claims))}) + } + } + return preds +} + +// predicatesToSQL renders resolved predicates into WHERE clauses and bound params. +func predicatesToSQL(preds []resolvedPredicate) ([]string, []any) { + var clauses []string + var params []any + for _, p := range preds { + // Quote the policy-authored column the same way the query builder quotes + // caller columns, so a row-filter on a weird-but-legal column name (dots, + // spaces, keywords) is emitted safely. + qcol := chsql.QuoteIdent(p.Column) + switch p.Op { + case "in": + if len(p.Values) == 0 { // An empty/unresolvable set fails closed: a row filter scoped to no // values matches no rows, never widening to all of them (the #224 // fail-open). `IN ()` is not valid SQL, so emit a constant false. clauses = append(clauses, "1 = 0") } else { - placeholders := strings.TrimSuffix(strings.Repeat("?,", len(vals)), ",") + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(p.Values)), ",") clauses = append(clauses, fmt.Sprintf("%s IN (%s)", qcol, placeholders)) - params = append(params, vals...) + for _, v := range p.Values { + params = append(params, v) + } } + default: + clauses = append(clauses, fmt.Sprintf("%s %s ?", qcol, p.Op)) + params = append(params, p.Values[0]) } } return clauses, params } +// resolveFilters converts filter definitions with claim templates into SQL WHERE +// clauses. Retained as the predicates→SQL composition the query-path tests target. +func resolveFilters(filters map[string]Filter, claims map[string]any) ([]string, []any) { + return predicatesToSQL(resolvePredicates(filters, claims)) +} + +// toStrings normalizes resolveInValues' []any (already stringified elements) to the +// []string a resolvedPredicate carries. +func toStrings(vals []any) []string { + if len(vals) == 0 { + return nil + } + out := make([]string, len(vals)) + for i, v := range vals { + out[i] = fmt.Sprint(v) + } + return out +} + // resolveTemplate resolves {{ jwt.claim.path }} templates against JWT claims. // If a claim path cannot be resolved, the template placeholder is replaced with // an empty string to prevent "" from leaking into SQL filters. diff --git a/internal/policy/rowfilter.go b/internal/policy/rowfilter.go new file mode 100644 index 00000000..efaf2f05 --- /dev/null +++ b/internal/policy/rowfilter.go @@ -0,0 +1,121 @@ +package policy + +import ( + "strconv" + "strings" +) + +// HasRowFilter reports whether this role/table entry carries a row-level-security +// predicate. The stream fan-out uses it to decide whether an event can be projected +// once for a whole role bucket (no filter) or must be checked per subscriber against +// that subscriber's claims (filter present). A nil receiver (no policy applies) has +// no filter. +func (p *ResolvedPermissions) HasRowFilter() bool { + return p != nil && len(p.rowFilter) > 0 +} + +// RowVisible reports whether row satisfies every resolved row-filter predicate — the +// in-memory twin of the query path's WHERE clause, evaluated against a decoded event +// so the stream applies the same row-level security the query path does. Predicates +// are ANDed; the query path joins them with AND too. +// +// numericCols maps column name → true when that column's ClickHouse type is numeric +// (the caller supplies it from the table schema; see discovery.IsNumericType). +// Numeric columns compare numerically, so 9 < 100 as ClickHouse would; every other +// column — and any column absent from the map, e.g. when no schema is available — +// compares as text. This is exact for the equality/set operators row-level security +// actually uses (=, !=, in) and best-effort for ordering (>, <): it cannot perfectly +// mirror ClickHouse's per-type coercion for exotic types (Decimal / Int128 beyond +// float64 precision, Date/DateTime stored as Unix numbers). Every ambiguous or +// uncomparable case fails closed — the row is hidden, never leaked — so the boundary +// costs availability, not confidentiality. +// +// A nil receiver (no policy applies) makes every row visible. +func (p *ResolvedPermissions) RowVisible(row map[string]any, numericCols map[string]bool) bool { + if p == nil { + return true + } + for _, pred := range p.rowFilter { + if !pred.matches(row, numericCols[pred.Column]) { + return false + } + } + return true +} + +// matches evaluates one predicate against the row, failing closed (false) whenever +// the value is absent or can't be compared as required. +func (pred resolvedPredicate) matches(row map[string]any, numeric bool) bool { + raw, ok := row[pred.Column] + if !ok { + return false // column not in the event ⇒ can't prove the row is allowed + } + switch pred.Op { + case "=": + c, ok := compareScalar(raw, pred.Values[0], numeric) + return ok && c == 0 + case "!=": + c, ok := compareScalar(raw, pred.Values[0], numeric) + return ok && c != 0 + case ">": + c, ok := compareScalar(raw, pred.Values[0], numeric) + return ok && c > 0 + case "<": + c, ok := compareScalar(raw, pred.Values[0], numeric) + return ok && c < 0 + case "in": + for _, v := range pred.Values { + if c, ok := compareScalar(raw, v, numeric); ok && c == 0 { + return true + } + } + return false + default: + return false + } +} + +// compareScalar compares an event value against a resolved filter value, returning +// -1/0/+1 and ok=false when the comparison can't be made (a non-scalar event value, +// or a numeric comparison whose operands don't parse as numbers). numeric selects +// numeric vs lexicographic ordering. +func compareScalar(rowVal any, filterVal string, numeric bool) (int, bool) { + s, ok := scalarString(rowVal) + if !ok { + return 0, false + } + if numeric { + a, err1 := strconv.ParseFloat(s, 64) + b, err2 := strconv.ParseFloat(filterVal, 64) + if err1 != nil || err2 != nil { + return 0, false + } + switch { + case a < b: + return -1, true + case a > b: + return 1, true + default: + return 0, true + } + } + return strings.Compare(s, filterVal), true +} + +// scalarString renders a JSON-decoded scalar as the canonical string compared +// against a (string-valued) filter. Non-scalars (arrays, objects, null) return +// ok=false so the predicate fails closed rather than guessing. JSON numbers arrive +// as float64 via encoding/json; -1 precision emits the shortest round-trip form +// without an exponent, so integer IDs read back as "123", not "1.23e+02". +func scalarString(v any) (string, bool) { + switch x := v.(type) { + case string: + return x, true + case float64: + return strconv.FormatFloat(x, 'f', -1, 64), true + case bool: + return strconv.FormatBool(x), true + default: + return "", false + } +} diff --git a/internal/policy/rowfilter_test.go b/internal/policy/rowfilter_test.go new file mode 100644 index 00000000..5b3b39a8 --- /dev/null +++ b/internal/policy/rowfilter_test.go @@ -0,0 +1,107 @@ +package policy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// evalRowFilter builds a one-role/one-table policy carrying filter and returns the +// permissions resolved against claims, so tests exercise the full +// resolvePredicates → RowVisible path the stream fan-out uses. +func evalRowFilter(t *testing.T, filter map[string]Filter, claims map[string]any) *ResolvedPermissions { + t.Helper() + p := &Policy{Tables: map[string]TablePolicy{ + "t": {Select: map[string]RolePermissions{"r": {Filter: filter}}}, + }} + return Evaluate(p, "r", "t", "select", claims) +} + +// TestRowVisible_EqualityScoping covers the canonical row-level-security shape — +// tenant_id = {{ jwt.tenant }} — including the fail-closed on a missing column that +// stops an event without the filtered field from slipping through. +func TestRowVisible_EqualityScoping(t *testing.T) { + t.Parallel() + perms := evalRowFilter(t, map[string]Filter{"tenant_id": {Eq: new("{{ jwt.tenant }}")}}, map[string]any{"tenant": "acme"}) + is := assert.New(t) + is.True(perms.HasRowFilter()) + is.True(perms.RowVisible(map[string]any{"tenant_id": "acme"}, nil)) + is.False(perms.RowVisible(map[string]any{"tenant_id": "globex"}, nil)) + is.False(perms.RowVisible(map[string]any{"other": "x"}, nil), "missing filtered column ⇒ fail closed") +} + +// TestRowVisible_InSet covers _in against an array claim (multi-tenant scoping) and +// the fail-closed empty-set case (absent claim never widens to all rows). +func TestRowVisible_InSet(t *testing.T) { + t.Parallel() + perms := evalRowFilter(t, map[string]Filter{"tenant_id": {In: new("{{ jwt.tenants }}")}}, map[string]any{"tenants": []any{"a", "b"}}) + assert.True(t, perms.RowVisible(map[string]any{"tenant_id": "b"}, nil)) + assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "c"}, nil)) + + empty := evalRowFilter(t, map[string]Filter{"tenant_id": {In: new("{{ jwt.tenants }}")}}, nil) + assert.True(t, empty.HasRowFilter()) + assert.False(t, empty.RowVisible(map[string]any{"tenant_id": "a"}, nil), "absent claim ⇒ empty set ⇒ no row matches") +} + +func TestRowVisible_Neq(t *testing.T) { + t.Parallel() + perms := evalRowFilter(t, map[string]Filter{"status": {Neq: new("deleted")}}, nil) + assert.True(t, perms.RowVisible(map[string]any{"status": "active"}, nil)) + assert.False(t, perms.RowVisible(map[string]any{"status": "deleted"}, nil)) +} + +// TestRowVisible_Ordering_NumericVsLexicographic is the schema-informed behavior: an +// event value of 9 is numerically LESS than 100 but lexicographically GREATER +// ("9" > "100"). With the column marked numeric the row is hidden (matching +// ClickHouse); the no-schema lexicographic fallback would leak it — the footgun the +// schema closes. +func TestRowVisible_Ordering_NumericVsLexicographic(t *testing.T) { + t.Parallel() + perms := evalRowFilter(t, map[string]Filter{"amount": {Gt: new("100")}}, nil) + row := map[string]any{"amount": float64(9)} + + assert.False(t, perms.RowVisible(row, map[string]bool{"amount": true}), "numeric: 9 is not > 100") + assert.True(t, perms.RowVisible(row, nil), `lexicographic fallback: "9" > "100"`) + assert.True(t, perms.RowVisible(map[string]any{"amount": float64(250)}, map[string]bool{"amount": true})) +} + +// TestRowVisible_NumericEquality_FloatFormatting: a JSON float64(100) equals the +// string filter value "100" under numeric comparison, so integer-valued numeric +// columns aren't tripped up by float formatting. +func TestRowVisible_NumericEquality_FloatFormatting(t *testing.T) { + t.Parallel() + perms := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("100")}}, nil) + num := map[string]bool{"amount": true} + assert.True(t, perms.RowVisible(map[string]any{"amount": float64(100)}, num)) + assert.False(t, perms.RowVisible(map[string]any{"amount": float64(101)}, num)) +} + +func TestRowVisible_NilReceiver_AllVisible(t *testing.T) { + t.Parallel() + var perms *ResolvedPermissions + assert.True(t, perms.RowVisible(map[string]any{"x": "y"}, nil)) + assert.False(t, perms.HasRowFilter()) +} + +// TestRowVisible_NonScalar_FailsClosed: a nested/array/null event value can't be +// compared to a scalar filter value, so the predicate fails closed rather than guess. +func TestRowVisible_NonScalar_FailsClosed(t *testing.T) { + t.Parallel() + perms := evalRowFilter(t, map[string]Filter{"tenant_id": {Eq: new("acme")}}, nil) + assert.False(t, perms.RowVisible(map[string]any{"tenant_id": []any{"acme"}}, nil)) + assert.False(t, perms.RowVisible(map[string]any{"tenant_id": nil}, nil)) +} + +// TestRowVisible_MultiplePredicates_AllMustPass: predicates are ANDed, matching the +// query path's "AND"-joined WHERE clause. +func TestRowVisible_MultiplePredicates_AllMustPass(t *testing.T) { + t.Parallel() + perms := evalRowFilter(t, map[string]Filter{ + "tenant_id": {Eq: new("{{ jwt.tenant }}")}, + "amount": {Gt: new("100")}, + }, map[string]any{"tenant": "acme"}) + num := map[string]bool{"amount": true} + assert.True(t, perms.RowVisible(map[string]any{"tenant_id": "acme", "amount": float64(250)}, num)) + assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "acme", "amount": float64(9)}, num), "amount fails") + assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "globex", "amount": float64(250)}, num), "tenant fails") +} diff --git a/internal/stream/hub.go b/internal/stream/hub.go index e84821fa..934556f4 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -5,25 +5,29 @@ import ( "encoding/json" "sync" + "github.com/Wave-RF/WaveHouse/internal/discovery" "github.com/Wave-RF/WaveHouse/internal/ingest" "github.com/Wave-RF/WaveHouse/internal/policy" ) -// Hub fans live events out to SSE subscribers, projecting and serializing each -// event ONCE per (topic, role) instead of once per subscriber — the #294 lever. -// Subscribers register under (topic, role); Broadcast decodes the event once, -// applies each subscribed role's column policy once, builds one SSE frame per -// role, and fans it to every member of that role's Bucket. +// Hub fans live events out to SSE subscribers. Column projection is serialized ONCE +// per (topic, role) instead of once per subscriber — the #294/#353 lever — because +// column visibility depends solely on the role+table policy entry, never on JWT +// claims, so the projected frame is identical for every subscriber of a role. // -// The (topic, role) key is sufficient because the live path's only per-subscriber -// transform is column filtering, which depends solely on the role+table policy -// entry — never on JWT claims (claims feed only row-level WHERE/CHECK, which the -// stream path does not apply). See projectData. +// Row-level security is the exception: a role's row-filter (RLS predicate) is +// resolved against each subscriber's claims, so two subscribers of the same role can +// be entitled to different rows. For a role that carries a row-filter, Broadcast +// therefore keeps the shared column projection but evaluates row visibility PER +// subscriber (ResolvedPermissions.RowVisible) before delivering — closing the +// query/stream RLS drift in #319. Roles without a row-filter keep the pure +// once-per-role fast path unchanged. See projectColumns. type Hub struct { - mu sync.RWMutex - topics map[string]*topicRoutes - policy *policy.Store // nil ⇒ policy filtering not configured (legacy passthrough) - metric *Metrics // nil-safe + mu sync.RWMutex + topics map[string]*topicRoutes + policy *policy.Store // nil ⇒ policy filtering not configured (legacy passthrough) + registry *discovery.SchemaRegistry // nil ⇒ no column types; row-filter ordering compares lexicographically + metric *Metrics // nil-safe } // topicRoutes holds the per-role buckets subscribed to one topic. @@ -33,9 +37,11 @@ type topicRoutes struct { // NewHub builds an event hub. A nil policy store passes every event through // unfiltered (the unwired-tests case); a non-nil store whose Get returns nil is a -// total lockout (a deleted/absent policy denies everyone). metric may be nil. -func NewHub(policyStore *policy.Store, metric *Metrics) *Hub { - return &Hub{topics: make(map[string]*topicRoutes), policy: policyStore, metric: metric} +// total lockout (a deleted/absent policy denies everyone). A nil registry disables +// numeric-aware row-filter comparison (ordering predicates fall back to +// lexicographic); metric may be nil. +func NewHub(policyStore *policy.Store, registry *discovery.SchemaRegistry, metric *Metrics) *Hub { + return &Hub{topics: make(map[string]*topicRoutes), policy: policyStore, registry: registry, metric: metric} } // Add registers sub to receive events for (topic, role), creating the role bucket @@ -100,10 +106,12 @@ type roleBucket struct { bucket Bucket } -// Broadcast projects raw — a published EventMessage JSON delivered on topic — -// once per subscribed role and fans the finished SSE frame to that role's bucket. -// The expensive work (decode, evaluate, marshal) happens once per distinct role, -// not once per subscriber. +// Broadcast projects raw — a published EventMessage JSON delivered on topic — and +// fans the finished SSE frame to each subscribed role's bucket. The column +// projection (decode, evaluate, marshal) happens once per distinct role. For a role +// that carries a row-level-security filter, that shared frame is still delivered only +// to the subscribers whose claims admit this row (evaluated per subscriber); a role +// without a filter takes the pure once-per-role fast path. func (h *Hub) Broadcast(topic string, raw []byte) { // Snapshot the role->bucket set under the read lock; do the unmarshal / project // / serialize outside it. Skip the decode entirely when nobody is listening. @@ -125,13 +133,43 @@ func (h *Hub) Broadcast(topic string, raw []byte) { decoded := json.Unmarshal(raw, &evt) == nil && evt.TableName != "" p, filter := h.snapshotPolicy() + // Column types for numeric-aware row-filter comparison — resolved lazily at most + // once per event, only when some role actually carries a row-filter, and reused + // across every filtered role and subscriber. + var numericCols map[string]bool + numericResolved := false + for _, rb := range roleBuckets { - wire, ok := project(p, filter, rb.role, &evt, raw, decoded) + wire, perms, ok := projectColumns(p, filter, rb.role, &evt, raw, decoded) if !ok { continue // denied table / invalid payload for this role } frame := Frame{Kind: KindEvent, Data: wire} + + if !perms.HasRowFilter() { + // No row-level security for this role: one projection serves the whole + // bucket, regardless of per-subscriber claims (the #294/#353 fast path). + for _, sub := range rb.bucket.Snapshot() { + if !sub.Send(frame) { + h.metric.FrameDropped(KindEvent) + } + } + continue + } + + // Row-level security applies. The column projection is claims-independent and + // shared, but whether each subscriber may see THIS row depends on its claims, so + // evaluate visibility per subscriber. Predicates read the full event data (a + // filter may key on a column the role can't SELECT), not the projected columns. + if !numericResolved { + numericCols = h.numericCols(evt.TableName) + numericResolved = true + } for _, sub := range rb.bucket.Snapshot() { + subPerms := policy.Evaluate(p, rb.role, evt.TableName, "select", sub.claims) + if !subPerms.RowVisible(evt.Data, numericCols) { + continue // this row is filtered out for this subscriber + } if !sub.Send(frame) { h.metric.FrameDropped(KindEvent) } @@ -139,6 +177,26 @@ func (h *Hub) Broadcast(topic string, raw []byte) { } } +// numericCols maps each of the table's columns to whether its ClickHouse type is +// numeric, so the row-filter evaluator compares numeric columns numerically (9 < 100) +// rather than lexicographically. nil when no schema is available (unknown table, or a +// Hub built without a registry), in which case ordering predicates fall back to +// lexicographic comparison. +func (h *Hub) numericCols(table string) map[string]bool { + if h.registry == nil { + return nil + } + schema := h.registry.Get(table) + if schema == nil { + return nil + } + m := make(map[string]bool, len(schema.Columns)) + for _, c := range schema.Columns { + m[c.Name] = discovery.IsNumericType(c.Type) + } + return m +} + // snapshotPolicy returns the current policy and whether filtering is configured. // filter is false only when no store is wired (legacy passthrough); a wired store // returning a nil policy is a deliberate lockout that Evaluate denies. @@ -149,50 +207,60 @@ func (h *Hub) snapshotPolicy() (p *policy.Policy, filter bool) { return h.policy.Get(), true } -// ReplayFrame projects a single gap-fill event for one role into a ready-to-write -// replay frame, or ok=false to skip it (denied table / invalid payload). It is a -// method so replay shares the Hub's policy store with the live fan-out — the -// handler can't accidentally project replay against a different (or nil) policy. -// The per-connection replay path uses this; the live path uses Broadcast, which -// projects once per role instead of once per connection. -func (h *Hub) ReplayFrame(role string, raw []byte) (Frame, bool) { +// ReplayFrame projects a single gap-fill event for one role+claims into a +// ready-to-write replay frame, or ok=false to skip it (denied table, invalid +// payload, or a row the claims aren't entitled to see). It is a method so replay +// shares the Hub's policy store and schema registry with the live fan-out — the +// handler can't accidentally project replay against a different (or nil) policy. The +// per-connection replay path uses this; the live path uses Broadcast. Replay is +// already per-connection, so it evaluates row-level security against this +// connection's claims directly. +func (h *Hub) ReplayFrame(role string, claims map[string]any, raw []byte) (Frame, bool) { var evt ingest.EventMessage decoded := json.Unmarshal(raw, &evt) == nil && evt.TableName != "" p, filter := h.snapshotPolicy() - wire, ok := project(p, filter, role, &evt, raw, decoded) + wire, perms, ok := projectColumns(p, filter, role, &evt, raw, decoded) if !ok { return Frame{}, false } + if perms.HasRowFilter() { + subPerms := policy.Evaluate(p, role, evt.TableName, "select", claims) + if !subPerms.RowVisible(evt.Data, h.numericCols(evt.TableName)) { + return Frame{}, false // this row is filtered out for these claims + } + } return Frame{Kind: KindReplay, Data: wire}, true } -// project applies role/table column policy to a decoded EventMessage (or passes a -// non-EventMessage JSON payload through untouched), returning the SSE wire frame. -// ok=false means skip: the role can't read the table, or the payload is unusable. -// -// claims are intentionally not consulted: column visibility derives only from the -// role+table policy entry (AllowColumns/DenyColumns), so the projection is -// byte-identical for every subscriber of a (role, table) regardless of claims — -// which is exactly what lets one serialization serve the whole bucket. If row-level -// filtering is ever added to the stream path, this key (and projection) must take -// claims into account. -func project(p *policy.Policy, filter bool, role string, evt *ingest.EventMessage, raw []byte, decoded bool) (wire []byte, ok bool) { +// projectColumns applies role/table COLUMN policy to a decoded EventMessage (or +// passes a non-EventMessage JSON payload through untouched), returning the SSE wire +// frame and the resolved permissions. Column visibility derives only from the +// role+table policy entry (AllowColumns/DenyColumns), never from claims, so this +// frame is byte-identical for every subscriber of a (role, table) — the shared +// projection that lets one serialization serve a whole bucket. Row-level security is +// NOT applied here; the caller checks perms.RowVisible per subscriber (with that +// subscriber's claims) when perms.HasRowFilter(). ok=false means skip: the role can't +// read the table, or the payload is unusable. perms is nil for the legacy no-policy +// passthrough (which has no row-filter). +func projectColumns(p *policy.Policy, filter bool, role string, evt *ingest.EventMessage, raw []byte, decoded bool) (wire []byte, perms *policy.ResolvedPermissions, ok bool) { if !decoded { // Without a decoded EventMessage there's no table to evaluate policy against, // so fail closed whenever policy is configured: a malformed-but-valid-JSON // payload on ingest. must not bypass column filtering. Pass through // only when no policy store is wired at all (the legacy/test passthrough). if filter || !json.Valid(raw) { - return nil, false + return nil, nil, false } - return wireFrame("", raw), true + return wireFrame("", raw), nil, true } data := evt.Data if filter { - perms := policy.Evaluate(p, role, evt.TableName, "select", nil) + // Column allow/deny is claims-independent, so evaluate it once with nil claims; + // the caller re-evaluates the row-filter per subscriber against real claims. + perms = policy.Evaluate(p, role, evt.TableName, "select", nil) if !perms.Allowed { - return nil, false // role has no access to this table + return nil, nil, false // role has no access to this table } data = filterColumns(evt.Data, perms) } @@ -203,9 +271,9 @@ func project(p *policy.Policy, filter bool, role string, evt *ingest.EventMessag "data": data, }) if err != nil { - return nil, false + return nil, nil, false } - return wireFrame(evt.ReceivedTimestamp, payload), true + return wireFrame(evt.ReceivedTimestamp, payload), perms, true } // filterColumns returns a copy of data containing only columns the role may see. diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index 311587fe..48a4a983 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/Wave-RF/WaveHouse/internal/discovery" "github.com/Wave-RF/WaveHouse/internal/ingest" "github.com/Wave-RF/WaveHouse/internal/policy" "github.com/stretchr/testify/assert" @@ -37,6 +38,17 @@ func recvFrame(t *testing.T, sub *Subscriber) Frame { } } +// assertNoFrame fails if sub has any frame buffered — used to prove a row-filtered +// subscriber received nothing for a row it isn't entitled to see. +func assertNoFrame(t *testing.T, sub *Subscriber) { + t.Helper() + select { + case f := <-sub.Frames(): + t.Fatalf("expected no frame, got %q", f.Data) + default: + } +} + // frameData parses the JSON object on the "data:" line of an SSE frame. func frameData(t *testing.T, f Frame) map[string]any { t.Helper() @@ -53,7 +65,7 @@ func frameData(t *testing.T, f Frame) map[string]any { func TestHub_ProjectsOncePerRole_FanOutToAllSubscribers(t *testing.T) { t.Parallel() - hub := NewHub(nil, nil) // nil store ⇒ passthrough, no filtering + hub := NewHub(nil, nil, nil) // nil store ⇒ passthrough, no filtering const topic = "ingest.clicks" a, b := NewSubscriber(), NewSubscriber() @@ -85,7 +97,7 @@ func TestHub_ProjectsPerRole_ColumnFilterAndDenial(t *testing.T) { }, }, } - hub := NewHub(policy.NewMemoryStore(p), nil) + hub := NewHub(policy.NewMemoryStore(p), nil, nil) const topic = "ingest.clicks" viewer := NewSubscriber() @@ -121,7 +133,7 @@ func TestHub_ProjectsPerRole_DistinctRolesGetDistinctFrames(t *testing.T) { }, }, } - hub := NewHub(policy.NewMemoryStore(p), nil) + hub := NewHub(policy.NewMemoryStore(p), nil, nil) const topic = "ingest.clicks" viewer, editor := NewSubscriber(), NewSubscriber() @@ -148,9 +160,132 @@ func TestHub_ProjectsPerRole_DistinctRolesGetDistinctFrames(t *testing.T) { assert.NotEqual(t, fv.Data, fe.Data, "distinct role projections produce distinct bytes") } +// rowFilterPolicy scopes role "viewer" to column "page" only, and to rows whose +// tenant_id equals the caller's {{ jwt.tenant }} claim. The filter keys on tenant_id +// — a column viewer may NOT select — so it also exercises the rule that row +// visibility is evaluated against the FULL event, then columns are projected. +func rowFilterPolicy() *policy.Policy { + return &policy.Policy{ + Tables: map[string]policy.TablePolicy{ + "clicks": { + Select: map[string]policy.RolePermissions{ + "viewer": { + AllowColumns: []string{"page"}, + Filter: map[string]policy.Filter{"tenant_id": {Eq: new("{{ jwt.tenant }}")}}, + }, + }, + }, + }, + } +} + +// TestHub_RowFilter_PerSubscriberIsolation is the #319 fix: two subscribers of the +// SAME role but different tenant claims each receive only their own tenant's rows +// over the live stream — the row-filter the query path applies is now applied here +// too, per subscriber. +func TestHub_RowFilter_PerSubscriberIsolation(t *testing.T) { + t.Parallel() + hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) + const topic = "ingest.clicks" + + acme := NewSubscriber() + acme.SetClaims(map[string]any{"tenant": "acme"}) + globex := NewSubscriber() + globex.SetClaims(map[string]any{"tenant": "globex"}) + hub.Add(topic, "viewer", acme) + hub.Add(topic, "viewer", globex) + + // An acme row reaches only the acme subscriber, projected to the allowed column. + hub.Broadcast(topic, rawEvent(t, "clicks", "2026-06-26T00:00:00Z", + map[string]any{"tenant_id": "acme", "page": "/a", "secret": "x"})) + inner := frameData(t, recvFrame(t, acme))["data"].(map[string]any) + assert.Equal(t, "/a", inner["page"]) + assert.NotContains(t, inner, "tenant_id", "the filtered column is not in viewer's projection") + assert.NotContains(t, inner, "secret", "denied column stripped") + assertNoFrame(t, globex) + + // A globex row reaches only the globex subscriber. + hub.Broadcast(topic, rawEvent(t, "clicks", "2026-06-26T00:00:01Z", + map[string]any{"tenant_id": "globex", "page": "/g"})) + assert.Equal(t, "/g", frameData(t, recvFrame(t, globex))["data"].(map[string]any)["page"]) + assertNoFrame(t, acme) +} + +// TestHub_RowFilter_MissingColumn_FailsClosed: an event that lacks the filtered +// column can't be proven visible, so it is withheld rather than leaked. +func TestHub_RowFilter_MissingColumn_FailsClosed(t *testing.T) { + t.Parallel() + hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) + const topic = "ingest.clicks" + + acme := NewSubscriber() + acme.SetClaims(map[string]any{"tenant": "acme"}) + hub.Add(topic, "viewer", acme) + + hub.Broadcast(topic, rawEvent(t, "clicks", "2026-06-26T00:00:00Z", + map[string]any{"page": "/a"})) // no tenant_id + assertNoFrame(t, acme) +} + +// TestHub_RowFilter_SharedProjectionAcrossSameClaims: the column projection is still +// serialized once per role and shared — two subscribers with the same (matching) +// claims receive the identical frame bytes. Only the visibility decision is +// per-subscriber, not the serialization. +func TestHub_RowFilter_SharedProjectionAcrossSameClaims(t *testing.T) { + t.Parallel() + hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) + const topic = "ingest.clicks" + + a, b := NewSubscriber(), NewSubscriber() + a.SetClaims(map[string]any{"tenant": "acme"}) + b.SetClaims(map[string]any{"tenant": "acme"}) + hub.Add(topic, "viewer", a) + hub.Add(topic, "viewer", b) + + hub.Broadcast(topic, rawEvent(t, "clicks", "2026-06-26T00:00:00Z", + map[string]any{"tenant_id": "acme", "page": "/a"})) + + fa, fb := recvFrame(t, a), recvFrame(t, b) + require.NotEmpty(t, fa.Data) + require.NotEmpty(t, fb.Data) + assert.Same(t, &fa.Data[0], &fb.Data[0], "one serialization shared across same-role subscribers") +} + +// TestHub_RowFilter_NumericOrdering_SchemaInformed drives the registry-backed path: +// with a numeric column type in the schema, an `amount > 100` filter compares +// numerically, so amount=9 is withheld (a lexicographic "9" > "100" comparison would +// have leaked it) and amount=250 is delivered. +func TestHub_RowFilter_NumericOrdering_SchemaInformed(t *testing.T) { + t.Parallel() + reg := discovery.NewSchemaRegistryFromMap([]*discovery.TableSchema{ + {Name: "clicks", Columns: []discovery.Column{ + {Name: "amount", Type: "UInt64"}, + {Name: "page", Type: "String"}, + }}, + }) + p := &policy.Policy{ + Tables: map[string]policy.TablePolicy{ + "clicks": {Select: map[string]policy.RolePermissions{ + "viewer": {Filter: map[string]policy.Filter{"amount": {Gt: new("100")}}}, + }}, + }, + } + hub := NewHub(policy.NewMemoryStore(p), reg, nil) + const topic = "ingest.clicks" + + sub := NewSubscriber() // constant filter value ⇒ no claims needed + hub.Add(topic, "viewer", sub) + + hub.Broadcast(topic, rawEvent(t, "clicks", "t1", map[string]any{"amount": float64(9), "page": "/a"})) + assertNoFrame(t, sub) // 9 is not numerically > 100 + + hub.Broadcast(topic, rawEvent(t, "clicks", "t2", map[string]any{"amount": float64(250), "page": "/b"})) + assert.Equal(t, float64(250), frameData(t, recvFrame(t, sub))["data"].(map[string]any)["amount"]) +} + func TestHub_TopicIsolation(t *testing.T) { t.Parallel() - hub := NewHub(nil, nil) + hub := NewHub(nil, nil, nil) clicks, views := NewSubscriber(), NewSubscriber() hub.Add("ingest.clicks", "public", clicks) hub.Add("ingest.views", "public", views) @@ -193,7 +328,7 @@ func TestHub_PassthroughAndFailClosed(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - hub := NewHub(tt.store, nil) + hub := NewHub(tt.store, nil, nil) const topic = "ingest.custom" sub := NewSubscriber() hub.Add(topic, "public", sub) @@ -214,7 +349,7 @@ func TestHub_PassthroughAndFailClosed(t *testing.T) { func TestHub_AddRemoveGCsBucketsAndTopics(t *testing.T) { t.Parallel() - hub := NewHub(nil, nil) + hub := NewHub(nil, nil, nil) const topic = "ingest.clicks" sub := NewSubscriber() @@ -235,7 +370,7 @@ func TestHub_AddRemoveGCsBucketsAndTopics(t *testing.T) { func TestHub_BroadcastNoSubscribers_NoOp(t *testing.T) { t.Parallel() - hub := NewHub(nil, nil) + hub := NewHub(nil, nil, nil) assert.NotPanics(t, func() { hub.Broadcast("ingest.nobody", rawEvent(t, "clicks", "t", map[string]any{"a": float64(1)})) }) @@ -252,7 +387,7 @@ func TestHub_SlowConsumerDropIncrementsMetric(t *testing.T) { otel.SetMeterProvider(savedMP) }) - hub := NewHub(nil, NewMetrics()) + hub := NewHub(nil, nil, NewMetrics()) const topic = "ingest.clicks" sub := newSubscriber(1) // cap-1: the second undrained broadcast drops hub.Add(topic, "public", sub) @@ -273,7 +408,7 @@ func TestHub_ReplayFrame(t *testing.T) { "clicks": {Select: map[string]policy.RolePermissions{"viewer": {AllowColumns: []string{"page"}}}}, }, } - hub := NewHub(policy.NewMemoryStore(p), nil) + hub := NewHub(policy.NewMemoryStore(p), nil, nil) raw := rawEvent(t, "clicks", "2026-06-26T00:00:00Z", map[string]any{"page": "/home", "secret": "x"}) tests := []struct { @@ -287,7 +422,7 @@ func TestHub_ReplayFrame(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - f, ok := hub.ReplayFrame(tt.role, raw) + f, ok := hub.ReplayFrame(tt.role, nil, raw) require.Equal(t, tt.want, ok) if !tt.want { return @@ -302,7 +437,7 @@ func TestHub_ReplayFrame(t *testing.T) { func TestHub_ConcurrentAddRemoveBroadcast_Race(t *testing.T) { t.Parallel() - hub := NewHub(nil, nil) + hub := NewHub(nil, nil, nil) const topic = "ingest.clicks" raw := rawEvent(t, "clicks", "t", map[string]any{"a": float64(1)}) diff --git a/internal/stream/subscriber.go b/internal/stream/subscriber.go index 253980e5..3a22b909 100644 --- a/internal/stream/subscriber.go +++ b/internal/stream/subscriber.go @@ -23,6 +23,13 @@ type Subscriber struct { out chan Frame // ready-to-write frames; see defaultSubscriberQueue bucket Bucket // set on Add so the keepalive wheel's Remove is O(1); nil until added + // claims are this connection's JWT claims, evaluated against a role's row-level + // security filter so the Hub can decide, per subscriber, whether each event row is + // visible (see Hub.Broadcast). Set once via SetClaims before Add, then read-only — + // so the fan-out goroutine reads it without synchronization. nil ⇒ no claims (a + // tokenless subscriber), which fails any claim-scoped row-filter closed. + claims map[string]any + // evict is closed (once) to ask the owning handler to disconnect a wedged slow // consumer; the handler selects on Evicted() and tears the stream down, after // which the client reconnects and gap-fills via Last-Event-ID. The seam is wired @@ -41,6 +48,12 @@ func newSubscriber(size int) *Subscriber { return &Subscriber{out: make(chan Frame, size), evict: make(chan struct{})} } +// SetClaims attaches this connection's JWT claims, which the Hub evaluates against a +// role's row-level-security filter to decide, per subscriber, whether each event row +// is visible. Call it before registering the subscriber with the Hub; the claims are +// then read-only for the subscriber's lifetime, so the fan-out reads them race-free. +func (s *Subscriber) SetClaims(claims map[string]any) { s.claims = claims } + // Frames is the queue of ready-to-write frames; the handler writes whatever // arrives here to the client verbatim. func (s *Subscriber) Frames() <-chan Frame { diff --git a/tests/integration/setup_test.go b/tests/integration/setup_test.go index bb3aecdb..348cabda 100644 --- a/tests/integration/setup_test.go +++ b/tests/integration/setup_test.go @@ -312,7 +312,7 @@ func buildServer(ch *chInstance, embeddedMQ *mq.EmbeddedNATS, registry *discover js := embeddedMQ.JetStream() policyStore := policy.NewMemoryStore(&policy.Policy{AdminRole: "admin"}) - streamHub := stream.NewHub(policyStore, nil) + streamHub := stream.NewHub(policyStore, registry, nil) deps := api.Dependencies{ Ingest: api.NewIngestHandler(registry, embeddedMQ, logger), From d534a251fb9fec9c91ec1669eb01690f7e5fdc15 Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 8 Jul 2026 08:44:17 -0400 Subject: [PATCH 02/27] Test case fixes --- docs/src/content/docs/access-control.mdx | 2 +- internal/stream/hub_test.go | 26 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index 13664a15..faae1027 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -370,7 +370,7 @@ The same policy drives every data path, but not every field is meaningful on eve | ------- | -------- | -------- | | Structured read | `POST /v1/query?table={table}` | table+role `select` required, then `allow`/`deny_columns`, row `filter`, aggregation rules, and the per-role `max_rows` / `max_execution_time` / `max_rows_to_read` / `max_memory_usage` caps (over the [ClickHouse server-wide limits](/configuration#server-side-resource-limits)) | | Ingest (write) | `POST /v1/ingest?table={table}` | table+role `insert` required, then `allow`/`deny_columns` and `check` (enforced and auto-injected) | -| Live stream | `GET /v1/stream` | table+role `select` required (a table the role can't read is skipped), then denied columns are masked from each event | +| Live stream | `GET /v1/stream` | table+role `select` required (a table the role can't read is skipped), then denied columns are masked from each event and the role's row `filter` is applied per subscriber against their JWT claims (see caution below) | | Raw SQL | `POST /v1/admin/query` | `admin_role` only — no per-statement policy; the role gate is the entire authorization story | | Named pipe | `GET/POST /v1/pipes/{name}` | per-pipe `allowed_roles` (not the policy engine; see [Named Pipes](/pipes)). Resource limits come from ClickHouse's [server-wide settings](/configuration#server-side-resource-limits), not per-role policy caps | diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index 48a4a983..f07d92cf 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -435,6 +435,32 @@ func TestHub_ReplayFrame(t *testing.T) { } } +// TestHub_ReplayFrame_RowFilter exercises the row-filter branch of ReplayFrame: the +// #319 fix applies row-level security on the per-connection replay path too, so a +// gap-fill event is projected only when the connection's claims satisfy the filter. +func TestHub_ReplayFrame_RowFilter(t *testing.T) { + t.Parallel() + hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) + raw := rawEvent(t, "clicks", "2026-06-26T00:00:00Z", + map[string]any{"tenant_id": "acme", "page": "/a", "secret": "x"}) + + t.Run("matching claims replay the row, projected to allowed columns", func(t *testing.T) { + t.Parallel() + f, ok := hub.ReplayFrame("viewer", map[string]any{"tenant": "acme"}, raw) + require.True(t, ok) + assert.Equal(t, KindReplay, f.Kind) + inner := frameData(t, f)["data"].(map[string]any) + assert.Equal(t, "/a", inner["page"]) + assert.NotContains(t, inner, "secret", "denied column stripped on replay too") + }) + + t.Run("non-matching claims withhold the row", func(t *testing.T) { + t.Parallel() + _, ok := hub.ReplayFrame("viewer", map[string]any{"tenant": "globex"}, raw) + require.False(t, ok, "row must be withheld when claims don't satisfy the filter") + }) +} + func TestHub_ConcurrentAddRemoveBroadcast_Race(t *testing.T) { t.Parallel() hub := NewHub(nil, nil, nil) From af7afb9ca5cf5ea97a75ca40ea6594b88457095e Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 8 Jul 2026 10:38:06 -0400 Subject: [PATCH 03/27] perf(stream): resolve only the row filter per subscriber on SSE fan-out --- internal/policy/policy.go | 111 +++++++++++++++++++++++++++--------- internal/stream/hub.go | 4 +- internal/stream/hub_test.go | 43 +++++++++++++- 3 files changed, 126 insertions(+), 32 deletions(-) diff --git a/internal/policy/policy.go b/internal/policy/policy.go index c0f5a4f3..4c466775 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -108,8 +108,14 @@ func ResolveRole(p *Policy, role string) string { return p.DefaultRole } -// Evaluate resolves a policy for a given role, table, and operation against JWT claims. -func Evaluate(p *Policy, role, table, operation string, claims map[string]any) *ResolvedPermissions { +// resolveRolePerms navigates the policy to the RolePermissions governing +// (role, table, operation), applying role resolution, the admin bypass, and +// default-deny. It returns admin=true for the unconditional-bypass role (perms is the +// zero value — the caller grants full access) and ok=false for any denial (perms is +// the zero value). Evaluate (query path) and EvaluateRowFilter (stream path) share +// this one navigation so they can never disagree on who is authorized or which policy +// entry applies. +func resolveRolePerms(p *Policy, role, table, operation string) (perms RolePermissions, admin, ok bool) { // Map an empty/absent role to the configured default_role (no-op if none, // or if the policy is nil). A non-empty role is unchanged — roles never // inherit the default's permissions. @@ -125,20 +131,20 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * // over HTTP), so a nil policy falls through to the deny just below. Mirrors // RoleAllowed's admin short-circuit on the pipe path. if IsAdmin(p, role) { - return &ResolvedPermissions{Allowed: true} + return RolePermissions{}, true, false } // Beyond here the role is non-admin (non-empty only if it carried a concrete // role or matched a real default_role); any failure to find a matching entry // is a plain deny. if p == nil { - return &ResolvedPermissions{Allowed: false} + return RolePermissions{}, false, false } - tp, ok := p.Tables[table] - if !ok { + tp, found := p.Tables[table] + if !found { // No policy for this table — default deny. - return &ResolvedPermissions{Allowed: false} + return RolePermissions{}, false, false } var rolePerms map[string]RolePermissions @@ -148,11 +154,11 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * case "insert": rolePerms = tp.Insert default: - return &ResolvedPermissions{Allowed: false} + return RolePermissions{}, false, false } if rolePerms == nil { - return &ResolvedPermissions{Allowed: false} + return RolePermissions{}, false, false } // An empty/absent role must never match a role entry — a roleless request is @@ -161,9 +167,18 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * // role key in a policy therefore grants nothing: the policy-side twin of the // empty-AllowedRoles-entry footgun closed for pipes in #159. Matching is // exact — there is no "*" any-role wildcard. - perms, ok := RolePermissions{}, false - if role != "" { - perms, ok = rolePerms[role] + if role == "" { + return RolePermissions{}, false, false + } + perms, ok = rolePerms[role] + return perms, false, ok +} + +// Evaluate resolves a policy for a given role, table, and operation against JWT claims. +func Evaluate(p *Policy, role, table, operation string, claims map[string]any) *ResolvedPermissions { + perms, admin, ok := resolveRolePerms(p, role, table, operation) + if admin { + return &ResolvedPermissions{Allowed: true} } if !ok { return &ResolvedPermissions{Allowed: false} @@ -181,24 +196,16 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * MaxMemoryUsage: perms.MaxMemoryUsage, } - // Resolve filters into WHERE clause. A bind-unsafe filter column can't be - // emitted safely — a '?' in it would shift clickhouse-go's positional value - // binding, including this RLS filter's own bound value — so deny the role - // fail-closed rather than drop the predicate (which would widen row access) - // or emit a mis-bound query. validateRolePerms rejects such a policy at write - // time; this guards the query path as defense-in-depth (Evaluate does not - // re-validate the policy it is handed). + // Resolve the row-filter once into predicates, then render both read surfaces + // from that single source so they can't drift: the query path binds them into a + // SQL WHERE here; the stream path evaluates the same predicates in memory + // (ResolvedPermissions.RowVisible, via EvaluateRowFilter). resolveFilterPredicates + // fails closed on a bind-unsafe column (see there). if len(perms.Filter) > 0 { - for col := range perms.Filter { - if chsql.BindUnsafe(col) { - return &ResolvedPermissions{Allowed: false} - } + preds, deny := resolveFilterPredicates(perms.Filter, claims) + if deny { + return &ResolvedPermissions{Allowed: false} } - // Resolve the row-filter once into predicates, then render both read surfaces - // from that single source so they can't drift: the query path binds them into - // a SQL WHERE here; the stream path evaluates the same predicates in memory - // (ResolvedPermissions.RowVisible). - preds := resolvePredicates(perms.Filter, claims) resolved.rowFilter = preds clauses, params := predicatesToSQL(preds) if len(clauses) > 0 { @@ -225,6 +232,54 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * return resolved } +// EvaluateRowFilter resolves ONLY the row-level-security predicates governing +// (role, table, operation) for the given claims, returning a *ResolvedPermissions the +// caller evaluates with RowVisible. It is the stream fan-out's per-subscriber twin of +// Evaluate: it shares Evaluate's role resolution, admin bypass, default-deny, and +// bind-safe predicate resolution — so the two can never disagree on which rows a +// subscriber may see — but skips the column, aggregation, resource-cap, and SQL-WHERE +// work Evaluate does, none of which an in-memory row check needs. That keeps a +// high-fan-out filtered topic from allocating and discarding a full ResolvedPermissions +// per subscriber per event (see stream.BenchmarkBroadcast_RowFilteredFanout). +// +// The result carries Allowed and the resolved rowFilter only. Admin and roles without +// a filter yield no predicates (RowVisible admits every row); a denied role or a +// bind-unsafe filter yields Allowed:false. The stream calls this only after its column +// projection has already confirmed the role may read the table, so Allowed is +// invariantly true at that call site and RowVisible alone decides visibility. +func EvaluateRowFilter(p *Policy, role, table, operation string, claims map[string]any) *ResolvedPermissions { + perms, admin, ok := resolveRolePerms(p, role, table, operation) + if admin { + return &ResolvedPermissions{Allowed: true} + } + if !ok { + return &ResolvedPermissions{Allowed: false} + } + preds, deny := resolveFilterPredicates(perms.Filter, claims) + if deny { + return &ResolvedPermissions{Allowed: false} + } + return &ResolvedPermissions{Allowed: true, rowFilter: preds} +} + +// resolveFilterPredicates resolves a role's row-filter map into predicates, first +// failing closed (deny=true) if any filter column is bind-unsafe: a '?' in the column +// would shift clickhouse-go's positional value binding — including this filter's own +// bound value — so the role is denied rather than the predicate dropped (which would +// widen row access) or a mis-bound query emitted. validateRolePerms rejects such a +// policy at write time; this is defense-in-depth (the resolver does not re-validate the +// policy it is handed). Shared by Evaluate (query path, which then renders SQL via +// predicatesToSQL) and EvaluateRowFilter (stream path, which evaluates in memory), so +// both reject the same unsafe policy and resolve identical predicates. +func resolveFilterPredicates(filters map[string]Filter, claims map[string]any) (preds []resolvedPredicate, deny bool) { + for col := range filters { + if chsql.BindUnsafe(col) { + return nil, true + } + } + return resolvePredicates(filters, claims), false +} + // resolvedPredicate is one row-filter comparison with its claim templates already // resolved to concrete string values — the shared, render-agnostic form the query // path turns into SQL (predicatesToSQL) and the stream path evaluates in memory diff --git a/internal/stream/hub.go b/internal/stream/hub.go index 934556f4..11785134 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -166,7 +166,7 @@ func (h *Hub) Broadcast(topic string, raw []byte) { numericResolved = true } for _, sub := range rb.bucket.Snapshot() { - subPerms := policy.Evaluate(p, rb.role, evt.TableName, "select", sub.claims) + subPerms := policy.EvaluateRowFilter(p, rb.role, evt.TableName, "select", sub.claims) if !subPerms.RowVisible(evt.Data, numericCols) { continue // this row is filtered out for this subscriber } @@ -224,7 +224,7 @@ func (h *Hub) ReplayFrame(role string, claims map[string]any, raw []byte) (Frame return Frame{}, false } if perms.HasRowFilter() { - subPerms := policy.Evaluate(p, role, evt.TableName, "select", claims) + subPerms := policy.EvaluateRowFilter(p, role, evt.TableName, "select", claims) if !subPerms.RowVisible(evt.Data, h.numericCols(evt.TableName)) { return Frame{}, false // this row is filtered out for these claims } diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index f07d92cf..6cf219fc 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -3,6 +3,7 @@ package stream import ( "context" "encoding/json" + "fmt" "strings" "sync" "testing" @@ -18,8 +19,9 @@ import ( "go.opentelemetry.io/otel/sdk/metric/metricdata" ) -// rawEvent marshals an EventMessage the way the ingest path publishes it. -func rawEvent(t *testing.T, table, ts string, data map[string]any) []byte { +// rawEvent marshals an EventMessage the way the ingest path publishes it. It takes +// testing.TB so both tests (*testing.T) and benchmarks (*testing.B) can build events. +func rawEvent(t testing.TB, table, ts string, data map[string]any) []byte { t.Helper() raw, err := json.Marshal(ingest.EventMessage{TableName: table, ReceivedTimestamp: ts, Data: data}) require.NoError(t, err) @@ -504,6 +506,43 @@ func TestWireFrame(t *testing.T) { } } +// BenchmarkBroadcast_RowFilteredFanout measures one Broadcast on a row-filtered +// topic as the subscriber count grows. Every subscriber of a filtered role triggers +// a per-subscriber policy.Evaluate + RowVisible on each event (hub.go Broadcast), so +// this exercises the O(subscribers) allocation path CodeRabbit flagged on PR #381. +// It isolates that fan-out cost: the shared column projection is built once per +// event, and full outbound queues merely drop (a nil-metric no-op), so the +// allocs/op reported here are dominated by the per-subscriber evaluation. +// +// go test ./internal/stream/ -run '^$' -bench BenchmarkBroadcast_RowFilteredFanout -benchmem +func BenchmarkBroadcast_RowFilteredFanout(b *testing.B) { + const topic = "ingest.clicks" + raw := rawEvent(b, "clicks", "2026-06-26T00:00:00Z", + map[string]any{"tenant_id": "acme", "page": "/a", "secret": "x"}) + + for _, n := range []int{100, 1_000, 10_000} { + b.Run(fmt.Sprintf("subscribers=%d", n), func(b *testing.B) { + hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) + // Half the subscribers share the event's tenant (row visible), half don't + // (row withheld); either way each still pays the per-subscriber Evaluate, + // which is the cost under measurement. + for i := range n { + sub := NewSubscriber() + tenant := "acme" + if i%2 == 1 { + tenant = "globex" + } + sub.SetClaims(map[string]any{"tenant": tenant}) + hub.Add(topic, "viewer", sub) + } + b.ReportAllocs() + for b.Loop() { + hub.Broadcast(topic, raw) + } + }) + } +} + // sumByName totals all datapoints of an Int64 sum instrument across kinds. func sumByName(rm metricdata.ResourceMetrics, name string) int64 { for _, sm := range rm.ScopeMetrics { From 898748088dca1bede032b89260b752c5a830d498 Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 8 Jul 2026 12:31:16 -0400 Subject: [PATCH 04/27] perf(stream): compile row filter once per bucket, bind per subscriber --- go.mod | 2 +- internal/policy/policy.go | 275 ++++++++++++++++++++++-------- internal/policy/rowfilter.go | 63 +++++++ internal/policy/rowfilter_test.go | 125 ++++++++++++++ internal/stream/hub.go | 11 +- internal/stream/hub_test.go | 15 +- 6 files changed, 407 insertions(+), 84 deletions(-) diff --git a/go.mod b/go.mod index 7a1c7d32..0ea6370e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Wave-RF/WaveHouse -go 1.26.4 +go 1.26.5 tool ( github.com/Zxilly/go-size-analyzer/cmd/gsa diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 4c466775..2274198a 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -112,7 +112,7 @@ func ResolveRole(p *Policy, role string) string { // (role, table, operation), applying role resolution, the admin bypass, and // default-deny. It returns admin=true for the unconditional-bypass role (perms is the // zero value — the caller grants full access) and ok=false for any denial (perms is -// the zero value). Evaluate (query path) and EvaluateRowFilter (stream path) share +// the zero value). Evaluate (query path) and CompileRowFilter (stream path) share // this one navigation so they can never disagree on who is authorized or which policy // entry applies. func resolveRolePerms(p *Policy, role, table, operation string) (perms RolePermissions, admin, ok bool) { @@ -198,9 +198,9 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * // Resolve the row-filter once into predicates, then render both read surfaces // from that single source so they can't drift: the query path binds them into a - // SQL WHERE here; the stream path evaluates the same predicates in memory - // (ResolvedPermissions.RowVisible, via EvaluateRowFilter). resolveFilterPredicates - // fails closed on a bind-unsafe column (see there). + // SQL WHERE here; the stream path evaluates the same compiled predicates in memory + // (CompiledRowFilter.RowVisible). resolveFilterPredicates fails closed on a + // bind-unsafe column (see there). if len(perms.Filter) > 0 { preds, deny := resolveFilterPredicates(perms.Filter, claims) if deny { @@ -232,90 +232,234 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * return resolved } -// EvaluateRowFilter resolves ONLY the row-level-security predicates governing -// (role, table, operation) for the given claims, returning a *ResolvedPermissions the -// caller evaluates with RowVisible. It is the stream fan-out's per-subscriber twin of -// Evaluate: it shares Evaluate's role resolution, admin bypass, default-deny, and -// bind-safe predicate resolution — so the two can never disagree on which rows a -// subscriber may see — but skips the column, aggregation, resource-cap, and SQL-WHERE -// work Evaluate does, none of which an in-memory row check needs. That keeps a -// high-fan-out filtered topic from allocating and discarding a full ResolvedPermissions -// per subscriber per event (see stream.BenchmarkBroadcast_RowFilteredFanout). +// resolveFilterPredicates resolves a role's row-filter map into predicates for the +// query path, first failing closed (deny=true) if any filter column is bind-unsafe: a +// '?' in the column would shift clickhouse-go's positional value binding — including +// this filter's own bound value — so the role is denied rather than the predicate +// dropped (which would widen row access) or a mis-bound query emitted. validateRolePerms +// rejects such a policy at write time; this is defense-in-depth (the resolver does not +// re-validate the policy it is handed). The stream path applies the same bind-unsafe +// gate in CompileRowFilter, so both read surfaces reject the same unsafe policy. +func resolveFilterPredicates(filters map[string]Filter, claims map[string]any) (preds []resolvedPredicate, deny bool) { + if filterBindUnsafe(filters) { + return nil, true + } + return resolvePredicates(filters, claims), false +} + +// filterBindUnsafe reports whether any filter column can't be bound safely (see +// resolveFilterPredicates for why that denies the role). Claims-independent, so the +// stream can check it once per bucket in CompileRowFilter. +func filterBindUnsafe(filters map[string]Filter) bool { + for col := range filters { + if chsql.BindUnsafe(col) { + return true + } + } + return false +} + +// ----------------------------------------------------------------------------- +// Compiled row filter: claims-independent structure, per-subscriber claim binding. // -// The result carries Allowed and the resolved rowFilter only. Admin and roles without -// a filter yield no predicates (RowVisible admits every row); a denied role or a -// bind-unsafe filter yields Allowed:false. The stream calls this only after its column -// projection has already confirmed the role may read the table, so Allowed is -// invariantly true at that call site and RowVisible alone decides visibility. -func EvaluateRowFilter(p *Policy, role, table, operation string, claims map[string]any) *ResolvedPermissions { - perms, admin, ok := resolveRolePerms(p, role, table, operation) - if admin { - return &ResolvedPermissions{Allowed: true} +// The stream fan-out resolves a role's row-filter ONCE per bucket into compiled +// predicates (CompileRowFilter), then binds each subscriber's claims at evaluation +// time (CompiledRowFilter.RowVisible). It is the deferred-binding twin of +// resolvePredicates, which eagerly resolves for the query path's single claim set. +// The two must agree on every (filter, claims) pair — that parity is pinned by +// TestCompiledRowFilter_MatchesEvaluatePath. The optimization is that a whole +// {{ jwt.path }} value binds via a map walk (navigateClaims) and a constant binds to +// itself, so a filtered high-fan-out topic pays no per-subscriber regexp/predicate +// allocation. +// ----------------------------------------------------------------------------- + +type valueKind uint8 + +const ( + valConstant valueKind = iota // literal, no claim template + valClaim // exactly one {{ jwt.path }} — bound via navigateClaims + valTemplate // text with embedded {{ jwt.… }} — bound via resolveTemplate +) + +// compiledValue is a filter value with its claim-template structure classified once, +// so binding a subscriber's claims is a map walk (valClaim) or a no-op (valConstant) +// rather than a regexp pass. +type compiledValue struct { + kind valueKind + s string // valConstant literal, or valTemplate raw text + path []string // valClaim: the pre-split jwt claim path +} + +// compileScalarValue classifies an _eq/_neq/_gt/_lt value the way resolveTemplate reads +// it — a substring replace, with a whole-value (no surrounding text) {{ jwt.path }} +// short-circuited to a direct claim lookup. It matches on the UNtrimmed value so a +// space-padded ref stays a template, exactly as resolveTemplate would keep the spaces. +func compileScalarValue(v string) compiledValue { + if m := wholeClaimRe.FindStringSubmatch(v); m != nil { + return compiledValue{kind: valClaim, path: strings.Split(m[1], ".")} } - if !ok { - return &ResolvedPermissions{Allowed: false} + if claimTemplateRe.MatchString(v) { + return compiledValue{kind: valTemplate, s: v} } - preds, deny := resolveFilterPredicates(perms.Filter, claims) - if deny { - return &ResolvedPermissions{Allowed: false} + return compiledValue{kind: valConstant, s: v} +} + +// compileInValue classifies an _in value the way resolveInValues reads it: a +// TrimSpace'd whole {{ jwt.path }} is a claim list; anything else is a single +// (possibly templated) value. +func compileInValue(v string) compiledValue { + if m := wholeClaimRe.FindStringSubmatch(strings.TrimSpace(v)); m != nil { + return compiledValue{kind: valClaim, path: strings.Split(m[1], ".")} + } + if claimTemplateRe.MatchString(v) { + return compiledValue{kind: valTemplate, s: v} } - return &ResolvedPermissions{Allowed: true, rowFilter: preds} + return compiledValue{kind: valConstant, s: v} } -// resolveFilterPredicates resolves a role's row-filter map into predicates, first -// failing closed (deny=true) if any filter column is bind-unsafe: a '?' in the column -// would shift clickhouse-go's positional value binding — including this filter's own -// bound value — so the role is denied rather than the predicate dropped (which would -// widen row access) or a mis-bound query emitted. validateRolePerms rejects such a -// policy at write time; this is defense-in-depth (the resolver does not re-validate the -// policy it is handed). Shared by Evaluate (query path, which then renders SQL via -// predicatesToSQL) and EvaluateRowFilter (stream path, which evaluates in memory), so -// both reject the same unsafe policy and resolve identical predicates. -func resolveFilterPredicates(filters map[string]Filter, claims map[string]any) (preds []resolvedPredicate, deny bool) { - for col := range filters { - if chsql.BindUnsafe(col) { - return nil, true +// bindScalar resolves a scalar value against claims — the deferred twin of +// resolveTemplate. A whole-claim ref returns the claim's string form (no allocation +// when the claim is already a string; fmt.Sprint otherwise, matching resolveTemplate), +// and a missing claim resolves to "" exactly as resolveTemplate does. +func (cv compiledValue) bindScalar(claims map[string]any) string { + switch cv.kind { + case valClaim: + val := navigateClaims(claims, cv.path) + if val == nil { + return "" + } + if s, ok := val.(string); ok { + return s } + return fmt.Sprint(val) + case valTemplate: + return resolveTemplate(cv.s, claims) + case valConstant: + return cv.s + default: + return cv.s // unreachable: every valueKind has an explicit case above } - return resolvePredicates(filters, claims), false } -// resolvedPredicate is one row-filter comparison with its claim templates already -// resolved to concrete string values — the shared, render-agnostic form the query -// path turns into SQL (predicatesToSQL) and the stream path evaluates in memory -// (RowVisible). Op is one of "=", "!=", ">", "<", "in". Values holds one element -// for the scalar operators and zero-or-more for "in" (empty ⇒ matches no rows). -type resolvedPredicate struct { +// bindIn resolves an _in value against claims into the set of bound values (the +// deferred twin of resolveInValues). A claim list yields one bound value per element; +// any other value yields the single scalar-bound value. +func (cv compiledValue) bindIn(claims map[string]any) []string { + if cv.kind == valClaim { + switch v := navigateClaims(claims, cv.path).(type) { + case nil: + return nil + case []any: + out := make([]string, 0, len(v)) + for _, e := range v { + out = append(out, fmt.Sprint(e)) + } + return out + default: + return []string{fmt.Sprint(v)} + } + } + return []string{cv.bindScalar(claims)} +} + +// compiledPredicate is one row-filter comparison with its value structure compiled but +// the claim binding deferred to evaluation time. +type compiledPredicate struct { Column string Op string - Values []string + Value compiledValue } -// resolvePredicates resolves each filter's claim templates once into predicates. -// Both read surfaces derive from this single result so they can't drift; the -// operator order within a column (=, !=, >, <, in) mirrors the former inline SQL. -func resolvePredicates(filters map[string]Filter, claims map[string]any) []resolvedPredicate { - var preds []resolvedPredicate +// compilePredicates compiles a role's row-filter into per-subscriber-bindable +// predicates, mirroring resolvePredicates' column/operator expansion (and order) so the +// compiled and resolved paths produce the same predicate set. +func compilePredicates(filters map[string]Filter) []compiledPredicate { + var preds []compiledPredicate for col, f := range filters { if f.Eq != nil { - preds = append(preds, resolvedPredicate{col, "=", []string{resolveTemplate(*f.Eq, claims)}}) + preds = append(preds, compiledPredicate{col, "=", compileScalarValue(*f.Eq)}) } if f.Neq != nil { - preds = append(preds, resolvedPredicate{col, "!=", []string{resolveTemplate(*f.Neq, claims)}}) + preds = append(preds, compiledPredicate{col, "!=", compileScalarValue(*f.Neq)}) } if f.Gt != nil { - preds = append(preds, resolvedPredicate{col, ">", []string{resolveTemplate(*f.Gt, claims)}}) + preds = append(preds, compiledPredicate{col, ">", compileScalarValue(*f.Gt)}) } if f.Lt != nil { - preds = append(preds, resolvedPredicate{col, "<", []string{resolveTemplate(*f.Lt, claims)}}) + preds = append(preds, compiledPredicate{col, "<", compileScalarValue(*f.Lt)}) } if f.In != nil { - preds = append(preds, resolvedPredicate{col, "in", toStrings(resolveInValues(*f.In, claims))}) + preds = append(preds, compiledPredicate{col, "in", compileInValue(*f.In)}) } } return preds } +// CompiledRowFilter is a role/table's row-level-security filter compiled once +// (claims-independent) so the stream fan-out can reuse it across every subscriber in a +// bucket: resolve it per bucket with CompileRowFilter, then call RowVisible per +// subscriber with that subscriber's claims. It binds claims at evaluation time, so a +// filtered high-fan-out topic pays no per-subscriber predicate resolution — the +// deferred-binding counterpart to the query path's resolvePredicates (which binds a +// single claim set eagerly for SQL). +type CompiledRowFilter struct { + allowed bool + preds []compiledPredicate +} + +// CompileRowFilter compiles the row-filter governing (role, table, operation), +// claims-independently. It shares Evaluate's role resolution, admin bypass, and +// default-deny (via resolveRolePerms) and the same bind-unsafe gate, so it agrees with +// the query path on who is authorized and which policy is rejected. Compile once per +// role bucket; the per-subscriber cost is then only RowVisible. +func CompileRowFilter(p *Policy, role, table, operation string) *CompiledRowFilter { + perms, admin, ok := resolveRolePerms(p, role, table, operation) + if admin { + return &CompiledRowFilter{allowed: true} // admin: no predicates ⇒ every row visible + } + if !ok { + return &CompiledRowFilter{allowed: false} + } + if filterBindUnsafe(perms.Filter) { + return &CompiledRowFilter{allowed: false} // fail closed, as Evaluate does + } + return &CompiledRowFilter{allowed: true, preds: compilePredicates(perms.Filter)} +} + +// resolvedPredicate is one row-filter comparison with its claim templates already +// resolved to concrete string values — the shared, render-agnostic form the query +// path turns into SQL (predicatesToSQL) and the stream path evaluates in memory +// (RowVisible). Op is one of "=", "!=", ">", "<", "in". Values holds one element +// for the scalar operators and zero-or-more for "in" (empty ⇒ matches no rows). +type resolvedPredicate struct { + Column string + Op string + Values []string +} + +// resolvePredicates resolves each filter's claim templates into predicates for the +// query path. It compiles the filter — the single, claims-independent source the stream +// path also uses (CompileRowFilter) — then binds this request's claims, so the two read +// surfaces derive from one place and can't drift. Operator order within a column +// (=, !=, >, <, in) mirrors compilePredicates and the former inline SQL. +func resolvePredicates(filters map[string]Filter, claims map[string]any) []resolvedPredicate { + var preds []resolvedPredicate + for _, cp := range compilePredicates(filters) { + preds = append(preds, cp.resolve(claims)) + } + return preds +} + +// resolve binds a compiled predicate's claim value(s) into a resolvedPredicate — the +// eager, query-path counterpart to compiledPredicate.visible (which the stream +// evaluates against the row in memory instead of materializing this form). +func (cp compiledPredicate) resolve(claims map[string]any) resolvedPredicate { + if cp.Op == "in" { + return resolvedPredicate{cp.Column, "in", cp.Value.bindIn(claims)} + } + return resolvedPredicate{cp.Column, cp.Op, []string{cp.Value.bindScalar(claims)}} +} + // predicatesToSQL renders resolved predicates into WHERE clauses and bound params. func predicatesToSQL(preds []resolvedPredicate) ([]string, []any) { var clauses []string @@ -353,19 +497,6 @@ func resolveFilters(filters map[string]Filter, claims map[string]any) ([]string, return predicatesToSQL(resolvePredicates(filters, claims)) } -// toStrings normalizes resolveInValues' []any (already stringified elements) to the -// []string a resolvedPredicate carries. -func toStrings(vals []any) []string { - if len(vals) == 0 { - return nil - } - out := make([]string, len(vals)) - for i, v := range vals { - out[i] = fmt.Sprint(v) - } - return out -} - // resolveTemplate resolves {{ jwt.claim.path }} templates against JWT claims. // If a claim path cannot be resolved, the template placeholder is replaced with // an empty string to prevent "" from leaking into SQL filters. diff --git a/internal/policy/rowfilter.go b/internal/policy/rowfilter.go index efaf2f05..12983ffe 100644 --- a/internal/policy/rowfilter.go +++ b/internal/policy/rowfilter.go @@ -75,6 +75,69 @@ func (pred resolvedPredicate) matches(row map[string]any, numeric bool) bool { } } +// HasRowFilter reports whether the compiled filter carries any predicate. A nil +// receiver, or an admin / unfiltered role, has none — the compiled twin of +// ResolvedPermissions.HasRowFilter. +func (c *CompiledRowFilter) HasRowFilter() bool { + return c != nil && len(c.preds) > 0 +} + +// RowVisible reports whether row satisfies every compiled predicate for the given +// claims — the per-subscriber, deferred-binding twin of ResolvedPermissions.RowVisible, +// and it must agree with it on every decision. A nil receiver (no policy applies) +// admits every row; a denied or bind-unsafe filter admits none (fail closed). +// numericCols is as in ResolvedPermissions.RowVisible. +func (c *CompiledRowFilter) RowVisible(row, claims map[string]any, numericCols map[string]bool) bool { + if c == nil { + return true + } + if !c.allowed { + return false + } + for _, pred := range c.preds { + if !pred.visible(row, claims, numericCols[pred.Column]) { + return false + } + } + return true +} + +// visible evaluates one compiled predicate against the row for the given claims. It +// binds the claim value at call time, then reuses compareScalar — the same comparison +// core as resolvedPredicate.matches — so the compiled and resolved paths can't drift on +// how values compare. Fails closed on an absent or uncomparable value, exactly as +// matches does. +func (cp compiledPredicate) visible(row, claims map[string]any, numeric bool) bool { + raw, ok := row[cp.Column] + if !ok { + return false // column not in the event ⇒ can't prove the row is allowed + } + if cp.Op == "in" { + for _, v := range cp.Value.bindIn(claims) { + if c, ok := compareScalar(raw, v, numeric); ok && c == 0 { + return true + } + } + return false + } + c, ok := compareScalar(raw, cp.Value.bindScalar(claims), numeric) + if !ok { + return false + } + switch cp.Op { + case "=": + return c == 0 + case "!=": + return c != 0 + case ">": + return c > 0 + case "<": + return c < 0 + default: + return false + } +} + // compareScalar compares an event value against a resolved filter value, returning // -1/0/+1 and ok=false when the comparison can't be made (a non-scalar event value, // or a numeric comparison whose operands don't parse as numbers). numeric selects diff --git a/internal/policy/rowfilter_test.go b/internal/policy/rowfilter_test.go index 5b3b39a8..10707448 100644 --- a/internal/policy/rowfilter_test.go +++ b/internal/policy/rowfilter_test.go @@ -1,9 +1,11 @@ package policy import ( + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // evalRowFilter builds a one-role/one-table policy carrying filter and returns the @@ -105,3 +107,126 @@ func TestRowVisible_MultiplePredicates_AllMustPass(t *testing.T) { assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "acme", "amount": float64(9)}, num), "amount fails") assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "globex", "amount": float64(250)}, num), "tenant fails") } + +// TestCompiledRowFilter_MatchesEvaluatePath pins the parity the compiled per-subscriber +// stream path depends on: for every (filter, claims, row) below, CompileRowFilter + +// RowVisible must return exactly what the query path (Evaluate → resolved predicates → +// matches) returns. Were the two to diverge, a subscriber could see rows the query path +// hides (or vice versa) — the +// row-level-security bug this path exists to prevent. The matrix exercises every operator, +// constant / whole-claim / mixed-template / space-padded / nested-path values, and +// missing columns, empty claim sets, and numeric-vs-lexicographic comparison. +func TestCompiledRowFilter_MatchesEvaluatePath(t *testing.T) { + t.Parallel() + filters := []map[string]Filter{ + {"tenant_id": {Eq: new("{{ jwt.tenant }}")}}, + {"tenant_id": {Neq: new("{{ jwt.tenant }}")}}, + {"amount": {Gt: new("100")}}, + {"amount": {Lt: new("{{ jwt.cap }}")}}, + {"status": {Eq: new("active")}}, // constant + {"region": {In: new("{{ jwt.regions }}")}}, // claim list + {"tag": {In: new("{{ jwt.tag }}")}}, // claim scalar + {"kind": {In: new("published")}}, // constant in-set + {"label": {Eq: new("v-{{ jwt.tier }}")}}, // mixed template + {"tenant_id": {Eq: new(" {{ jwt.tenant }} ")}}, // space-padded ⇒ stays a template + {"tenant_id": {Eq: new("{{ jwt.tenant }}"), Neq: new("blocked")}}, // two predicates on one column + {"org": {Eq: new("{{ jwt.org.id }}")}}, // nested claim path + } + claimsets := []map[string]any{ + nil, + {"tenant": "acme"}, + {"tenant": "acme", "cap": float64(500), "regions": []any{"us", "eu"}, "tag": "x", "tier": "pro", "org": map[string]any{"id": "o1"}}, + {"tenant": ""}, + {"tenant": "acme", "regions": []any{}}, // empty claim list + } + rows := []map[string]any{ + {"tenant_id": "acme", "amount": float64(250), "status": "active", "region": "us", "tag": "x", "kind": "published", "label": "v-pro", "org": "o1"}, + {"tenant_id": "globex", "amount": float64(9), "status": "off", "region": "apac", "tag": "y", "kind": "draft", "label": "v-free", "org": "o2"}, + {"amount": float64(500)}, // several columns missing ⇒ fail closed + {"tenant_id": "", "amount": "NaN", "org": "o1"}, + } + numeric := map[string]bool{"amount": true} + + for fi, f := range filters { + p := &Policy{Tables: map[string]TablePolicy{ + "t": {Select: map[string]RolePermissions{"viewer": {Filter: f}}}, + }} + compiled := CompileRowFilter(p, "viewer", "t", "select") + assert.Equalf(t, Evaluate(p, "viewer", "t", "select", nil).HasRowFilter(), compiled.HasRowFilter(), + "HasRowFilter parity, filter#%d", fi) + for _, claims := range claimsets { + resolved := Evaluate(p, "viewer", "t", "select", claims) + for ri, row := range rows { + want := resolved.RowVisible(row, numeric) + got := compiled.RowVisible(row, claims, numeric) + assert.Equalf(t, want, got, + "filter#%d claims=%v row#%d — compiled=%v resolved=%v", fi, claims, ri, got, want) + } + } + } +} + +// FuzzCompiledRowFilterParity is the property form of the matrix test above: for ANY +// operator, filter value, claim, and row the fuzzer synthesizes, the compiled +// per-subscriber stream path (CompileRowFilter + RowVisible) must agree with the query +// path (Evaluate + resolved predicates). The fuzzer probes the template-parsing edges +// where a divergence would hide — stray "{{", "jwt.", nested dots, whitespace, partial +// templates — that a hand-written matrix can't enumerate. The seed corpus also runs as a +// plain regression test under `go test` (no -fuzz needed). The column is fixed to a +// bind-safe name so the role always resolves as allowed; RowVisible parity is defined +// only for an allowed role (a denied role never reaches the per-subscriber check). +func FuzzCompiledRowFilterParity(f *testing.F) { + f.Add(uint8(0), "{{ jwt.x }}", "acme", "acme", true, false, false) // eq claim-ref, match + f.Add(uint8(1), " {{ jwt.x }} ", "acme", "acme", true, false, false) // space-padded ⇒ template + f.Add(uint8(2), "100", "", "250", true, true, false) // gt numeric constant + f.Add(uint8(3), "{{ jwt.x }}", "500", "250", true, true, false) // lt numeric claim + f.Add(uint8(4), "{{ jwt.x }}", "a,b,c", "b", true, false, true) // in claim-list + f.Add(uint8(0), "v-{{ jwt.x }}", "pro", "v-pro", true, false, false) // mixed template + f.Add(uint8(0), "{{ jwt.x }}", "acme", "acme", false, false, false) // column absent ⇒ fail closed + f.Add(uint8(0), "{{ jwt.a.b }}", "z", "z", true, false, false) // nested claim path + + f.Fuzz(func(t *testing.T, opSel uint8, filterVal, claimVal, rowVal string, colPresent, numeric, claimList bool) { + var filter Filter + switch opSel % 5 { + case 0: + filter.Eq = &filterVal + case 1: + filter.Neq = &filterVal + case 2: + filter.Gt = &filterVal + case 3: + filter.Lt = &filterVal + case 4: + filter.In = &filterVal + } + p := &Policy{Tables: map[string]TablePolicy{ + "t": {Select: map[string]RolePermissions{"r": {Filter: map[string]Filter{"col": filter}}}}, + }} + + var claim any = claimVal + if claimList { // exercise the _in []any path, not just a scalar claim + parts := strings.Split(claimVal, ",") + list := make([]any, len(parts)) + for i, s := range parts { + list[i] = s + } + claim = list + } + claims := map[string]any{"x": claim} + row := map[string]any{} + if colPresent { + row["col"] = rowVal + } + nc := map[string]bool{} + if numeric { + nc["col"] = true + } + + compiled := CompileRowFilter(p, "r", "t", "select") + resolved := Evaluate(p, "r", "t", "select", claims) + require.True(t, resolved.Allowed, "fixed bind-safe setup must resolve as allowed") + require.Equalf(t, resolved.RowVisible(row, nc), compiled.RowVisible(row, claims, nc), + "parity broke: op=%d filter=%q claim=%v(list=%v) row=%q(present=%v) numeric=%v", + opSel%5, filterVal, claim, claimList, rowVal, colPresent, numeric) + }) +} diff --git a/internal/stream/hub.go b/internal/stream/hub.go index 11785134..eae6da8c 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -161,13 +161,16 @@ func (h *Hub) Broadcast(topic string, raw []byte) { // shared, but whether each subscriber may see THIS row depends on its claims, so // evaluate visibility per subscriber. Predicates read the full event data (a // filter may key on a column the role can't SELECT), not the projected columns. + // The filter compiles once per bucket (claims-independent); only the claim + // binding inside RowVisible is per subscriber, so a filtered high-fan-out topic + // pays no per-subscriber predicate resolution. if !numericResolved { numericCols = h.numericCols(evt.TableName) numericResolved = true } + compiled := policy.CompileRowFilter(p, rb.role, evt.TableName, "select") for _, sub := range rb.bucket.Snapshot() { - subPerms := policy.EvaluateRowFilter(p, rb.role, evt.TableName, "select", sub.claims) - if !subPerms.RowVisible(evt.Data, numericCols) { + if !compiled.RowVisible(evt.Data, sub.claims, numericCols) { continue // this row is filtered out for this subscriber } if !sub.Send(frame) { @@ -224,8 +227,8 @@ func (h *Hub) ReplayFrame(role string, claims map[string]any, raw []byte) (Frame return Frame{}, false } if perms.HasRowFilter() { - subPerms := policy.EvaluateRowFilter(p, role, evt.TableName, "select", claims) - if !subPerms.RowVisible(evt.Data, h.numericCols(evt.TableName)) { + compiled := policy.CompileRowFilter(p, role, evt.TableName, "select") + if !compiled.RowVisible(evt.Data, claims, h.numericCols(evt.TableName)) { return Frame{}, false // this row is filtered out for these claims } } diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index 6cf219fc..1a75683d 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -506,13 +506,14 @@ func TestWireFrame(t *testing.T) { } } -// BenchmarkBroadcast_RowFilteredFanout measures one Broadcast on a row-filtered -// topic as the subscriber count grows. Every subscriber of a filtered role triggers -// a per-subscriber policy.Evaluate + RowVisible on each event (hub.go Broadcast), so -// this exercises the O(subscribers) allocation path CodeRabbit flagged on PR #381. -// It isolates that fan-out cost: the shared column projection is built once per -// event, and full outbound queues merely drop (a nil-metric no-op), so the -// allocs/op reported here are dominated by the per-subscriber evaluation. +// BenchmarkBroadcast_RowFilteredFanout measures one Broadcast on a row-filtered topic +// as the subscriber count grows. The filter compiles once per bucket +// (policy.CompileRowFilter); each subscriber then pays only a claim-bound RowVisible on +// the full event, so allocations stay flat regardless of fan-out. This is the benchmark +// behind the O(subscribers) → O(1) allocation work on PR #381 — run it before/after a +// fan-out change to catch a regression back to per-subscriber resolution. It isolates +// the fan-out cost: the shared column projection is built once per event, and full +// outbound queues merely drop (a nil-metric no-op). // // go test ./internal/stream/ -run '^$' -bench BenchmarkBroadcast_RowFilteredFanout -benchmem func BenchmarkBroadcast_RowFilteredFanout(b *testing.B) { From 0e0c55a2d555b4f2006c8e9465872cf526b1e217 Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 8 Jul 2026 12:53:49 -0400 Subject: [PATCH 05/27] additional e2e test changes for coverage --- tests/e2e/sdk/streaming.test.ts | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/tests/e2e/sdk/streaming.test.ts b/tests/e2e/sdk/streaming.test.ts index c6b4a5ea..a43d0792 100644 --- a/tests/e2e/sdk/streaming.test.ts +++ b/tests/e2e/sdk/streaming.test.ts @@ -1,6 +1,6 @@ import type { Policy } from "@wavehouse/sdk"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { adminClient, dataClient, publicClient, testId, waitForCondition } from "./helpers.js"; +import { adminClient, authClient, dataClient, publicClient, testId, waitForCondition } from "./helpers.js"; import { suiteTables } from "./tables.js"; describe("Streaming", () => { @@ -18,10 +18,18 @@ describe("Streaming", () => { const publicPolicy = structuredClone(baselinePolicy); publicPolicy.default_role = "anon"; - // Explicitly allow the 'anon' role to SELECT (stream) from this suite's tables + // Explicitly allow the 'anon' role to SELECT (stream) from this suite's tables. + // 'scoped' additionally carries a per-subscriber row filter — streamed rows are + // limited to the caller's own country claim — so the SSE fan-out exercises the + // row-level-security path (CompileRowFilter → RowVisible) end to end, not just + // column projection. publicPolicy.tables[T.clicks].select = { ...(publicPolicy.tables[T.clicks].select || {}), anon: { allow_columns: ["*"] }, + scoped: { + allow_columns: ["*"], + filter: { country: { _eq: "{{ jwt.country }}" } }, + }, }; publicPolicy.tables[T.events].select = { ...(publicPolicy.tables[T.events].select || {}), @@ -115,5 +123,49 @@ describe("Streaming", () => { stream.close(); } }); + + it("applies the role's row filter per subscriber (row-level scoping)", async () => { + // Two subscribers, same 'scoped' role but different country claims. The role's + // filter (country = {{ jwt.country }}) must be evaluated per subscriber against + // the full event, so each sees only its own country's rows over the live stream — + // the #319 row-level-security guarantee, exercised end to end on SSE. + const inserter = dataClient(); // viewer may insert into this suite's tables + const usClient = authClient("scoped", { country: "US" }); + const caClient = authClient("scoped", { country: "CA" }); + const usEvents: any[] = []; + const caEvents: any[] = []; + const usId = testId(); + const caId = testId(); + + const usStream = usClient.from(T.clicks).stream(); + const caStream = caClient.from(T.clicks).stream(); + let unsubUs: (() => void) | undefined; + let unsubCa: (() => void) | undefined; + try { + unsubUs = usStream.subscribe({ next: (e) => usEvents.push(e), error: (err) => console.error("US SSE error:", err) }); + unsubCa = caStream.subscribe({ next: (e) => caEvents.push(e), error: (err) => console.error("CA SSE error:", err) }); + await usStream.connected(20_000); + await caStream.connected(20_000); + + // One row per country; the filter must route each to only its matching subscriber. + const base = { page: "/scoped", user_id: "u", session_id: "s", duration_ms: 1 }; + await inserter.from(T.clicks).insert({ ...base, event_id: usId, country: "US" }); + await inserter.from(T.clicks).insert({ ...base, event_id: caId, country: "CA" }); + + // Each subscriber receives its own country's row. Waiting for BOTH positives + // proves both rows were broadcast, so the cross-absence checks below are real + // (a row filtered out at the source can never arrive later). + await waitForCondition(() => usEvents.some((e) => e.data?.event_id === usId), 10_000); + await waitForCondition(() => caEvents.some((e) => e.data?.event_id === caId), 10_000); + + expect(usEvents.some((e) => e.data?.event_id === caId)).toBe(false); + expect(caEvents.some((e) => e.data?.event_id === usId)).toBe(false); + } finally { + if (unsubUs) unsubUs(); + if (unsubCa) unsubCa(); + usStream.close(); + caStream.close(); + } + }); }); }); From dc7e902392de72ff4faba19bbc74f31ccb979b3f Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 8 Jul 2026 13:35:41 -0400 Subject: [PATCH 06/27] make fix --- tests/e2e/sdk/streaming.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/e2e/sdk/streaming.test.ts b/tests/e2e/sdk/streaming.test.ts index a43d0792..1dc58e0f 100644 --- a/tests/e2e/sdk/streaming.test.ts +++ b/tests/e2e/sdk/streaming.test.ts @@ -1,6 +1,13 @@ import type { Policy } from "@wavehouse/sdk"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { adminClient, authClient, dataClient, publicClient, testId, waitForCondition } from "./helpers.js"; +import { + adminClient, + authClient, + dataClient, + publicClient, + testId, + waitForCondition, +} from "./helpers.js"; import { suiteTables } from "./tables.js"; describe("Streaming", () => { @@ -142,8 +149,14 @@ describe("Streaming", () => { let unsubUs: (() => void) | undefined; let unsubCa: (() => void) | undefined; try { - unsubUs = usStream.subscribe({ next: (e) => usEvents.push(e), error: (err) => console.error("US SSE error:", err) }); - unsubCa = caStream.subscribe({ next: (e) => caEvents.push(e), error: (err) => console.error("CA SSE error:", err) }); + unsubUs = usStream.subscribe({ + next: (e) => usEvents.push(e), + error: (err) => console.error("US SSE error:", err), + }); + unsubCa = caStream.subscribe({ + next: (e) => caEvents.push(e), + error: (err) => console.error("CA SSE error:", err), + }); await usStream.connected(20_000); await caStream.connected(20_000); From 0a756f8089ba902bf324d717b74599e2206e1937 Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 8 Jul 2026 14:09:48 -0400 Subject: [PATCH 07/27] updated comment --- internal/stream/hub_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index 1a75683d..130b1765 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -525,8 +525,9 @@ func BenchmarkBroadcast_RowFilteredFanout(b *testing.B) { b.Run(fmt.Sprintf("subscribers=%d", n), func(b *testing.B) { hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) // Half the subscribers share the event's tenant (row visible), half don't - // (row withheld); either way each still pays the per-subscriber Evaluate, - // which is the cost under measurement. + // (row withheld); either way each pays the per-subscriber RowVisible check + // against the once-per-bucket compiled filter, which is the cost under + // measurement. for i := range n { sub := NewSubscriber() tenant := "acme" From dd38086c526407e2830cd68a44e274e3d77d4c5f Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 8 Jul 2026 15:38:43 -0400 Subject: [PATCH 08/27] fix(policy): fail closed on NaN and compare float64-equal row-filter ties at full precision --- CHANGELOG.md | 2 +- docs/src/content/docs/access-control.mdx | 2 +- docs/src/content/docs/architecture.md | 2 +- internal/policy/rowfilter.go | 27 +++++++++++++++++-- internal/policy/rowfilter_test.go | 34 ++++++++++++++++++++++++ internal/stream/hub.go | 7 ++--- 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index faf59ded..a41fc54d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/discovery/validation_test.go`, `internal/stream/hub_test.go`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). Equality and set predicates (`_eq`/`_neq`/`_in` — the tenant/user scoping row-security actually uses) are enforced exactly; ordering predicates (`_gt`/`_lt`) are schema-informed — numeric columns compare numerically (matching ClickHouse), other types lexicographically — which is best-effort for exotic types stored as numbers (a documented boundary in access-control.mdx), with every ambiguous or uncomparable value failing closed (the row is withheld). Replay (gap-fill) applies the same per-connection row check. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant noted in the #294/#353 Changed entry below. +- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/discovery/validation_test.go`, `internal/stream/hub_test.go`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`policy.CompileRowFilter` → `CompiledRowFilter.RowVisible`, compiled once per role bucket and claim-bound per subscriber) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). Equality and set predicates (`_eq`/`_neq`/`_in` — the tenant/user scoping row-security actually uses) are enforced exactly — numeric ties beyond `float64` precision resolve at arbitrary precision (`math/big`), so the string-encoded 64-bit IDs ingest accepts to survive JS precision loss never falsely collide, and a `NaN` operand fails closed rather than comparing equal to everything — with one representation caveat: a `Bool`/`DateTime` filter value compares against the event's canonical text form, without ClickHouse's cross-representation coercion (`true`, not `1`); ordering predicates (`_gt`/`_lt`) are schema-informed — numeric columns compare numerically (matching ClickHouse), other types lexicographically — which is best-effort for exotic types stored as numbers (a documented boundary in access-control.mdx), with every ambiguous or uncomparable value failing closed (the row is withheld). Replay (gap-fill) applies the same per-connection row check. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant noted in the #294/#353 Changed entry below. - **Policy `_in` is now enforced on both the row-`filter` and insert-`check` paths, closing a fail-open row-security gap** (`internal/policy/policy.go`, `internal/api/ingest.go`, `docs/src/content/docs/access-control.mdx`, plus tests in `internal/policy/policy_test.go`, `internal/api/ingest_test.go`): closes #224. The `Filter` schema accepted `_in` but the engine never read it: on the row-`filter`/SELECT path `resolveFilters` had no `_in` branch, so a row-security filter like `tenant_id: { _in: … }` produced **no `WHERE` predicate** and the role saw every row instead of its tenant subset (a fail-open, same family as #223); on the `check`/INSERT path only `_eq` was honored, silently dropping any other operator. `_in` now takes a single claim that resolves to a JSON **array** (the multi-tenant case — a token's `tenant_ids` list) and emits `col IN (?, …)` with one bound param per element; a scalar claim is a one-element set, and an empty/absent claim matches **no rows** (fail-closed) rather than widening to all of them. On the insert path an `_in` check requires the column be present and one of the set — there is no single value to auto-inject as `_eq` does, so an omitted column is rejected (`403 check failed`). The comparison operators are enforced on `filter` (`_eq`/`_neq`/`_gt`/`_lt`/`_in` all produce predicates now, so nothing is rejected there) and, on `check`, `_neq`/`_gt`/`_lt` become a loud config-load rejection (no insert-time semantics; `check` honors `_eq` + `_in`). The `_in` value stays a single templated string in the wire schema (Go `Filter.In`, SDK `PolicyFilter._in`), matching the established "set = array" shape of the caller-query `in` operator. - **`denied_aggregations` is now enforced case-insensitively against the caller-supplied function name, closing a policy bypass** (`internal/policy/policy.go`, plus tests in `internal/policy/policy_test.go`): closes #318. `IsAggregationAllowed` lower-cased the aggregation name only *after* the deny-list loop and the empty-allow-list early return, so the deny check compared a lower-cased deny entry against the raw caller input — a denied aggregation slipped past simply by changing case (`SUM` bypassed a `sum` deny entry, and with an empty allow list the call was then permitted). `isValidAggFn` already accepts any casing and the SQL builder emits the function name verbatim, so the denied aggregation actually executed. The case fold now happens once, before the deny check, so deny wins regardless of caller casing — matching the case-insensitive contract the access-control docs already specified. - **A role's structured-query resource caps are now enforced server-side by ClickHouse, so a public read can't outrun its budget during a server-side scan/merge/aggregation phase** (`internal/api/ch_settings.go` (new), `internal/api/structured_query.go`, `internal/policy/policy.go`, `internal/policy/scalars.go` (new), `internal/config/config.go`, `internal/query/builder.go`, `cmd/wavehouse/main.go`, `config.yaml`, `clients/ts/src/types.ts`, `docs/src/content/docs/{access-control.mdx,configuration.mdx}`, plus tests in `internal/api/ch_settings_test.go`, `internal/policy/{policy,scalars}_test.go`, `internal/config/config_test.go`, `internal/query/builder_test.go`, `tests/integration/query_limits_test.go` (all new/expanded), `tests/e2e/sdk/query.test.ts`): closes #316. The native ClickHouse connection passed **no per-query `Settings`**, so a read's policy caps bound it only client-side — a Go `context` deadline (which cancels only while the client is reading result blocks) plus a SQL `LIMIT`. Memory and rows scanned were **never bounded server-side**, so a heavy aggregation could allocate gigabytes of state or scan an entire table well within the time budget; and because `clickhouse-go` derives a `max_execution_time` setting from the context deadline only for deadlines `> 1s`, a sub-second time cap reached ClickHouse with no server-side time bound at all. The structured-query path now attaches per-query `Settings` derived from the role's resolved permissions: `max_execution_time` (fractional seconds, emitted explicitly so the sub-second case is enforced), `max_result_rows` + `result_overflow_mode=throw` (defense-in-depth behind the SQL `LIMIT`), `max_rows_to_read` + `read_overflow_mode=throw`, and `max_memory_usage` — so a query that exceeds its budget is rejected by the server (ClickHouse codes 158 / 241) rather than running to completion. **Boundary:** WaveHouse owns the *dynamic, per-role* caps (sent as per-query settings); the *global, static* backstop — which applies to every query including named pipes and raw admin SQL — is configured in **ClickHouse's own settings profiles and quotas** (documented in `configuration.mdx`), composes with the per-role caps, and holds even against a WaveHouse bug. **Schema (pre-launch):** the per-role policy fields are human-readable in / numeric out — `max_execution_time_ms` (int) → **`max_execution_time`** (set as a duration string `"5s"` or a bare ms number; read back as ms), the new **`max_memory_usage`** (set as a size string `"4GiB"` — IEC/SI respected via `dustin/go-humanize`, so `4GB` ≠ `4GiB` — or a bare byte number; read back as bytes), and the new **`max_rows_to_read`** (int), backed by two small `Millis`/`ByteSize` types in the `policy` package. The formerly hard-coded `query.DefaultMaxRows = 10000` result-LIMIT becomes the documented, tunable `query.default_max_rows` config knob (`Build` takes it as a parameter). Raw admin SQL remains unbounded by WaveHouse (governed by ClickHouse). Verified RED before the fix: the handler-level integration test confirmed a capped read returned the full result set when the settings weren't sent. diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index faae1027..3804b0af 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -375,7 +375,7 @@ The same policy drives every data path, but not every field is meaningful on eve | Named pipe | `GET/POST /v1/pipes/{name}` | per-pipe `allowed_roles` (not the policy engine; see [Named Pipes](/pipes)). Resource limits come from ClickHouse's [server-wide settings](/configuration#server-side-resource-limits), not per-role policy caps | :::caution[Live streams enforce column and row policy, but not resource limits] -SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Equality and set predicates (`_eq`, `_neq`, `_in`) — the usual tenant/user scoping — are enforced exactly. Ordering predicates (`_gt`, `_lt`) are best-effort: the stream compares numeric columns numerically (matching ClickHouse) but lacks ClickHouse's full per-type coercion, so an ordering filter on an exotic type (a `Decimal`/`Int128` beyond `float64` precision, or a `DateTime` ingested as a Unix number) can admit or withhold a row the query path wouldn't. The resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) remain a property of the SQL query path and are **not** applied to the live event stream; if those caps are part of a role's isolation story, don't rely on them over the stream. +SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Equality and set predicates (`_eq`, `_neq`, `_in`) — the usual tenant/user scoping — are enforced exactly: numeric ties resolve at full precision (an ID beyond `float64`'s 2^53 never falsely matches a neighbor) and an unparseable or `NaN` operand withholds the row. One representation caveat: a `Bool` (or `DateTime`) value compares by its canonical text form, without ClickHouse's cross-representation coercion — write the filter value the way your events carry it (`true`, not `1`). Ordering predicates (`_gt`, `_lt`) are best-effort: the stream compares numeric columns numerically (matching ClickHouse) but lacks ClickHouse's full per-type coercion, so an ordering filter on an exotic type (a `Decimal`/`Int128` beyond `float64` precision, or a `DateTime` ingested as a Unix number) can admit or withhold a row the query path wouldn't. The resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) remain a property of the SQL query path and are **not** applied to the live event stream; if those caps are part of a role's isolation story, don't rely on them over the stream. ::: ## Managing the policy diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index f35d5e80..c13b49de 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -85,7 +85,7 @@ The API layer uses [Chi](https://github.com/go-chi/chi) for routing with Request The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)) lives next to the keepalive primitives it shares. One abstraction per file. -- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the numeric-aware comparison seeded from the schema registry). This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayFrame` shares the same projection and per-connection row check for the handler's gap-fill. +- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`CompiledRowFilter.RowVisible`, evaluated against the full event via the numeric-aware comparison seeded from the schema registry). This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayFrame` shares the same projection and per-connection row check for the handler's gap-fill. - **subscriber.go** — `Subscriber`, the per-connection handle. It owns a single ready-to-write outbound queue of `Frame`s (each tagged with its `kind`, so the handler labels the write where it happens): producers — the keepalive wheel and the event `Hub` — fan frames in with `Send` (non-blocking; a full queue drops and the `Hub` counts it), and the handler drains `Frames()` to the client verbatim. The queue is sized for buffering live events (cap 64, up from the keepalive-only cap 1; #152 will make it a knob), and an `Evicted()` channel is the seam the slow-consumer follow-up closes to disconnect a wedged consumer. - **bucket.go** — `Bucket`, the reusable fan-out primitive: a concurrency-safe set of subscribers. `Push` delivers a shared `Frame` to each fire-and-forget (the keepalive wheel's ring); `Snapshot` exposes the members so the event `Hub` can fan out while inspecting each `Send` result (to count drops). The `Hub` holds one `Bucket` per `(topic, role)` so a projected frame is built once and sent to every member instead of re-projected per subscriber. - **heartbeat.go** — The keepalive wheel (`Heartbeater`). A single process-wide ticker fans a minimal `:` comment across the ring of `Bucket`s, waking ~1/N of live streams per tick so the writes don't synchronize. The effective per-connection keepalive period is `stream.keepalive_interval` (the wheel ticks every `keepalive_interval ÷ keepalive_buckets`, so one rotation spans the interval); the owning handler goroutine does the actual write, so the shared ticker never touches a `ResponseWriter` directly. diff --git a/internal/policy/rowfilter.go b/internal/policy/rowfilter.go index 12983ffe..92101851 100644 --- a/internal/policy/rowfilter.go +++ b/internal/policy/rowfilter.go @@ -1,6 +1,8 @@ package policy import ( + "math" + "math/big" "strconv" "strings" ) @@ -150,7 +152,9 @@ func compareScalar(rowVal any, filterVal string, numeric bool) (int, bool) { if numeric { a, err1 := strconv.ParseFloat(s, 64) b, err2 := strconv.ParseFloat(filterVal, 64) - if err1 != nil || err2 != nil { + // NaN must be rejected explicitly: ParseFloat accepts "NaN", and NaN's + // three-way comparison reads as "equal to everything" below — a fail-open. + if err1 != nil || err2 != nil || math.IsNaN(a) || math.IsNaN(b) { return 0, false } switch { @@ -159,12 +163,31 @@ func compareScalar(rowVal any, filterVal string, numeric bool) (int, bool) { case a > b: return 1, true default: - return 0, true + // Float equality is not proof of equality: distinct integers beyond + // 2^53 (string-encoded on ingest exactly to survive JS precision loss) + // collapse to one float64, and rounding is monotonic so only the + // equal case is in doubt. Resolve the tie at full precision. + return compareExact(s, filterVal) } } return strings.Compare(s, filterVal), true } +// compareExact compares two numeric strings at arbitrary precision — the tie-break +// for operands float64 cannot tell apart. ok=false when either side isn't an exact +// rational (±Inf, malformed), failing the predicate closed. +func compareExact(a, b string) (int, bool) { + ra, ok := new(big.Rat).SetString(a) + if !ok { + return 0, false + } + rb, ok := new(big.Rat).SetString(b) + if !ok { + return 0, false + } + return ra.Cmp(rb), true +} + // scalarString renders a JSON-decoded scalar as the canonical string compared // against a (string-valued) filter. Non-scalars (arrays, objects, null) return // ok=false so the predicate fails closed rather than guessing. JSON numbers arrive diff --git a/internal/policy/rowfilter_test.go b/internal/policy/rowfilter_test.go index 10707448..8c7a804f 100644 --- a/internal/policy/rowfilter_test.go +++ b/internal/policy/rowfilter_test.go @@ -78,6 +78,40 @@ func TestRowVisible_NumericEquality_FloatFormatting(t *testing.T) { assert.False(t, perms.RowVisible(map[string]any{"amount": float64(101)}, num)) } +// TestRowVisible_NaN_FailsClosed: strconv.ParseFloat accepts "NaN", and NaN's +// three-way comparison would otherwise read as "equal to everything" — a fail-open +// that delivers a row the query path's WHERE excludes. Either operand parsing to +// NaN must withhold the row instead. +func TestRowVisible_NaN_FailsClosed(t *testing.T) { + t.Parallel() + num := map[string]bool{"amount": true} + + byRow := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("100")}}, nil) + assert.False(t, byRow.RowVisible(map[string]any{"amount": "NaN"}, num), "NaN row value must not equal 100") + + byClaim := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("{{ jwt.cap }}")}}, map[string]any{"cap": "NaN"}) + assert.False(t, byClaim.RowVisible(map[string]any{"amount": float64(100)}, num), "NaN claim must not match any row") + + neq := evalRowFilter(t, map[string]Filter{"amount": {Neq: new("NaN")}}, nil) + assert.False(t, neq.RowVisible(map[string]any{"amount": float64(100)}, num), "NaN is uncomparable, so even != fails closed") +} + +// TestRowVisible_NumericEquality_ExactBeyondFloat64: ingest accepts string-encoded +// numerics precisely so 64-bit IDs survive JS precision loss; equality must not +// collapse distinct IDs that round to the same float64 (adjacent values past 2^53). +func TestRowVisible_NumericEquality_ExactBeyondFloat64(t *testing.T) { + t.Parallel() + num := map[string]bool{"id": true} + + perms := evalRowFilter(t, map[string]Filter{"id": {Eq: new("9007199254740993")}}, nil) + assert.False(t, perms.RowVisible(map[string]any{"id": "9007199254740992"}, num), "float64-equal neighbors are not equal") + assert.True(t, perms.RowVisible(map[string]any{"id": "9007199254740993"}, num)) + assert.True(t, perms.RowVisible(map[string]any{"id": "9007199254740993.0"}, num), "same value in a different rendering still matches") + + neq := evalRowFilter(t, map[string]Filter{"id": {Neq: new("9007199254740993")}}, nil) + assert.True(t, neq.RowVisible(map[string]any{"id": "9007199254740992"}, num), "the exact tie-break keeps distinct IDs unequal for !=") +} + func TestRowVisible_NilReceiver_AllVisible(t *testing.T) { t.Parallel() var perms *ResolvedPermissions diff --git a/internal/stream/hub.go b/internal/stream/hub.go index eae6da8c..c8af8390 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -19,7 +19,7 @@ import ( // resolved against each subscriber's claims, so two subscribers of the same role can // be entitled to different rows. For a role that carries a row-filter, Broadcast // therefore keeps the shared column projection but evaluates row visibility PER -// subscriber (ResolvedPermissions.RowVisible) before delivering — closing the +// subscriber (CompiledRowFilter.RowVisible) before delivering — closing the // query/stream RLS drift in #319. Roles without a row-filter keep the pure // once-per-role fast path unchanged. See projectColumns. type Hub struct { @@ -241,8 +241,9 @@ func (h *Hub) ReplayFrame(role string, claims map[string]any, raw []byte) (Frame // role+table policy entry (AllowColumns/DenyColumns), never from claims, so this // frame is byte-identical for every subscriber of a (role, table) — the shared // projection that lets one serialization serve a whole bucket. Row-level security is -// NOT applied here; the caller checks perms.RowVisible per subscriber (with that -// subscriber's claims) when perms.HasRowFilter(). ok=false means skip: the role can't +// NOT applied here; when perms.HasRowFilter(), the caller compiles the role's filter +// once (policy.CompileRowFilter) and checks its RowVisible per subscriber with that +// subscriber's claims. ok=false means skip: the role can't // read the table, or the payload is unusable. perms is nil for the legacy no-policy // passthrough (which has no row-filter). func projectColumns(p *policy.Policy, filter bool, role string, evt *ingest.EventMessage, raw []byte, decoded bool) (wire []byte, perms *policy.ResolvedPermissions, ok bool) { From 2379bc48653399ea986703392612c9f612cc9bfd Mon Sep 17 00:00:00 2001 From: taitelee Date: Thu, 9 Jul 2026 08:39:52 -0400 Subject: [PATCH 09/27] refactor(policy): drop compiled row filter; evaluate per subscriber --- CHANGELOG.md | 2 +- docs/src/content/docs/architecture.md | 2 +- internal/policy/policy.go | 302 +++++--------------------- internal/policy/rowfilter.go | 68 +----- internal/policy/rowfilter_test.go | 125 ----------- internal/stream/hub.go | 18 +- internal/stream/hub_test.go | 45 +--- tests/e2e/sdk/streaming.test.ts | 2 +- 8 files changed, 75 insertions(+), 489 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a41fc54d..cbcf5c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/discovery/validation_test.go`, `internal/stream/hub_test.go`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`policy.CompileRowFilter` → `CompiledRowFilter.RowVisible`, compiled once per role bucket and claim-bound per subscriber) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). Equality and set predicates (`_eq`/`_neq`/`_in` — the tenant/user scoping row-security actually uses) are enforced exactly — numeric ties beyond `float64` precision resolve at arbitrary precision (`math/big`), so the string-encoded 64-bit IDs ingest accepts to survive JS precision loss never falsely collide, and a `NaN` operand fails closed rather than comparing equal to everything — with one representation caveat: a `Bool`/`DateTime` filter value compares against the event's canonical text form, without ClickHouse's cross-representation coercion (`true`, not `1`); ordering predicates (`_gt`/`_lt`) are schema-informed — numeric columns compare numerically (matching ClickHouse), other types lexicographically — which is best-effort for exotic types stored as numbers (a documented boundary in access-control.mdx), with every ambiguous or uncomparable value failing closed (the row is withheld). Replay (gap-fill) applies the same per-connection row check. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant noted in the #294/#353 Changed entry below. +- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/discovery/validation_test.go`, `internal/stream/hub_test.go`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). Equality and set predicates (`_eq`/`_neq`/`_in` — the tenant/user scoping row-security actually uses) are enforced exactly — numeric ties beyond `float64` precision resolve at arbitrary precision (`math/big`), so the string-encoded 64-bit IDs ingest accepts to survive JS precision loss never falsely collide, and a `NaN` operand fails closed rather than comparing equal to everything — with one representation caveat: a `Bool`/`DateTime` filter value compares against the event's canonical text form, without ClickHouse's cross-representation coercion (`true`, not `1`); ordering predicates (`_gt`/`_lt`) are schema-informed — numeric columns compare numerically (matching ClickHouse), other types lexicographically — which is best-effort for exotic types stored as numbers (a documented boundary in access-control.mdx), with every ambiguous or uncomparable value failing closed (the row is withheld). Replay (gap-fill) applies the same per-connection row check. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant noted in the #294/#353 Changed entry below. - **Policy `_in` is now enforced on both the row-`filter` and insert-`check` paths, closing a fail-open row-security gap** (`internal/policy/policy.go`, `internal/api/ingest.go`, `docs/src/content/docs/access-control.mdx`, plus tests in `internal/policy/policy_test.go`, `internal/api/ingest_test.go`): closes #224. The `Filter` schema accepted `_in` but the engine never read it: on the row-`filter`/SELECT path `resolveFilters` had no `_in` branch, so a row-security filter like `tenant_id: { _in: … }` produced **no `WHERE` predicate** and the role saw every row instead of its tenant subset (a fail-open, same family as #223); on the `check`/INSERT path only `_eq` was honored, silently dropping any other operator. `_in` now takes a single claim that resolves to a JSON **array** (the multi-tenant case — a token's `tenant_ids` list) and emits `col IN (?, …)` with one bound param per element; a scalar claim is a one-element set, and an empty/absent claim matches **no rows** (fail-closed) rather than widening to all of them. On the insert path an `_in` check requires the column be present and one of the set — there is no single value to auto-inject as `_eq` does, so an omitted column is rejected (`403 check failed`). The comparison operators are enforced on `filter` (`_eq`/`_neq`/`_gt`/`_lt`/`_in` all produce predicates now, so nothing is rejected there) and, on `check`, `_neq`/`_gt`/`_lt` become a loud config-load rejection (no insert-time semantics; `check` honors `_eq` + `_in`). The `_in` value stays a single templated string in the wire schema (Go `Filter.In`, SDK `PolicyFilter._in`), matching the established "set = array" shape of the caller-query `in` operator. - **`denied_aggregations` is now enforced case-insensitively against the caller-supplied function name, closing a policy bypass** (`internal/policy/policy.go`, plus tests in `internal/policy/policy_test.go`): closes #318. `IsAggregationAllowed` lower-cased the aggregation name only *after* the deny-list loop and the empty-allow-list early return, so the deny check compared a lower-cased deny entry against the raw caller input — a denied aggregation slipped past simply by changing case (`SUM` bypassed a `sum` deny entry, and with an empty allow list the call was then permitted). `isValidAggFn` already accepts any casing and the SQL builder emits the function name verbatim, so the denied aggregation actually executed. The case fold now happens once, before the deny check, so deny wins regardless of caller casing — matching the case-insensitive contract the access-control docs already specified. - **A role's structured-query resource caps are now enforced server-side by ClickHouse, so a public read can't outrun its budget during a server-side scan/merge/aggregation phase** (`internal/api/ch_settings.go` (new), `internal/api/structured_query.go`, `internal/policy/policy.go`, `internal/policy/scalars.go` (new), `internal/config/config.go`, `internal/query/builder.go`, `cmd/wavehouse/main.go`, `config.yaml`, `clients/ts/src/types.ts`, `docs/src/content/docs/{access-control.mdx,configuration.mdx}`, plus tests in `internal/api/ch_settings_test.go`, `internal/policy/{policy,scalars}_test.go`, `internal/config/config_test.go`, `internal/query/builder_test.go`, `tests/integration/query_limits_test.go` (all new/expanded), `tests/e2e/sdk/query.test.ts`): closes #316. The native ClickHouse connection passed **no per-query `Settings`**, so a read's policy caps bound it only client-side — a Go `context` deadline (which cancels only while the client is reading result blocks) plus a SQL `LIMIT`. Memory and rows scanned were **never bounded server-side**, so a heavy aggregation could allocate gigabytes of state or scan an entire table well within the time budget; and because `clickhouse-go` derives a `max_execution_time` setting from the context deadline only for deadlines `> 1s`, a sub-second time cap reached ClickHouse with no server-side time bound at all. The structured-query path now attaches per-query `Settings` derived from the role's resolved permissions: `max_execution_time` (fractional seconds, emitted explicitly so the sub-second case is enforced), `max_result_rows` + `result_overflow_mode=throw` (defense-in-depth behind the SQL `LIMIT`), `max_rows_to_read` + `read_overflow_mode=throw`, and `max_memory_usage` — so a query that exceeds its budget is rejected by the server (ClickHouse codes 158 / 241) rather than running to completion. **Boundary:** WaveHouse owns the *dynamic, per-role* caps (sent as per-query settings); the *global, static* backstop — which applies to every query including named pipes and raw admin SQL — is configured in **ClickHouse's own settings profiles and quotas** (documented in `configuration.mdx`), composes with the per-role caps, and holds even against a WaveHouse bug. **Schema (pre-launch):** the per-role policy fields are human-readable in / numeric out — `max_execution_time_ms` (int) → **`max_execution_time`** (set as a duration string `"5s"` or a bare ms number; read back as ms), the new **`max_memory_usage`** (set as a size string `"4GiB"` — IEC/SI respected via `dustin/go-humanize`, so `4GB` ≠ `4GiB` — or a bare byte number; read back as bytes), and the new **`max_rows_to_read`** (int), backed by two small `Millis`/`ByteSize` types in the `policy` package. The formerly hard-coded `query.DefaultMaxRows = 10000` result-LIMIT becomes the documented, tunable `query.default_max_rows` config knob (`Build` takes it as a parameter). Raw admin SQL remains unbounded by WaveHouse (governed by ClickHouse). Verified RED before the fix: the handler-level integration test confirmed a capped read returned the full result set when the settings weren't sent. diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index c13b49de..f35d5e80 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -85,7 +85,7 @@ The API layer uses [Chi](https://github.com/go-chi/chi) for routing with Request The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)) lives next to the keepalive primitives it shares. One abstraction per file. -- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`CompiledRowFilter.RowVisible`, evaluated against the full event via the numeric-aware comparison seeded from the schema registry). This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayFrame` shares the same projection and per-connection row check for the handler's gap-fill. +- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the numeric-aware comparison seeded from the schema registry). This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayFrame` shares the same projection and per-connection row check for the handler's gap-fill. - **subscriber.go** — `Subscriber`, the per-connection handle. It owns a single ready-to-write outbound queue of `Frame`s (each tagged with its `kind`, so the handler labels the write where it happens): producers — the keepalive wheel and the event `Hub` — fan frames in with `Send` (non-blocking; a full queue drops and the `Hub` counts it), and the handler drains `Frames()` to the client verbatim. The queue is sized for buffering live events (cap 64, up from the keepalive-only cap 1; #152 will make it a knob), and an `Evicted()` channel is the seam the slow-consumer follow-up closes to disconnect a wedged consumer. - **bucket.go** — `Bucket`, the reusable fan-out primitive: a concurrency-safe set of subscribers. `Push` delivers a shared `Frame` to each fire-and-forget (the keepalive wheel's ring); `Snapshot` exposes the members so the event `Hub` can fan out while inspecting each `Send` result (to count drops). The `Hub` holds one `Bucket` per `(topic, role)` so a projected frame is built once and sent to every member instead of re-projected per subscriber. - **heartbeat.go** — The keepalive wheel (`Heartbeater`). A single process-wide ticker fans a minimal `:` comment across the ring of `Bucket`s, waking ~1/N of live streams per tick so the writes don't synchronize. The effective per-connection keepalive period is `stream.keepalive_interval` (the wheel ticks every `keepalive_interval ÷ keepalive_buckets`, so one rotation spans the interval); the owning handler goroutine does the actual write, so the shared ticker never touches a `ResponseWriter` directly. diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 2274198a..c0f5a4f3 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -108,14 +108,8 @@ func ResolveRole(p *Policy, role string) string { return p.DefaultRole } -// resolveRolePerms navigates the policy to the RolePermissions governing -// (role, table, operation), applying role resolution, the admin bypass, and -// default-deny. It returns admin=true for the unconditional-bypass role (perms is the -// zero value — the caller grants full access) and ok=false for any denial (perms is -// the zero value). Evaluate (query path) and CompileRowFilter (stream path) share -// this one navigation so they can never disagree on who is authorized or which policy -// entry applies. -func resolveRolePerms(p *Policy, role, table, operation string) (perms RolePermissions, admin, ok bool) { +// Evaluate resolves a policy for a given role, table, and operation against JWT claims. +func Evaluate(p *Policy, role, table, operation string, claims map[string]any) *ResolvedPermissions { // Map an empty/absent role to the configured default_role (no-op if none, // or if the policy is nil). A non-empty role is unchanged — roles never // inherit the default's permissions. @@ -131,20 +125,20 @@ func resolveRolePerms(p *Policy, role, table, operation string) (perms RolePermi // over HTTP), so a nil policy falls through to the deny just below. Mirrors // RoleAllowed's admin short-circuit on the pipe path. if IsAdmin(p, role) { - return RolePermissions{}, true, false + return &ResolvedPermissions{Allowed: true} } // Beyond here the role is non-admin (non-empty only if it carried a concrete // role or matched a real default_role); any failure to find a matching entry // is a plain deny. if p == nil { - return RolePermissions{}, false, false + return &ResolvedPermissions{Allowed: false} } - tp, found := p.Tables[table] - if !found { + tp, ok := p.Tables[table] + if !ok { // No policy for this table — default deny. - return RolePermissions{}, false, false + return &ResolvedPermissions{Allowed: false} } var rolePerms map[string]RolePermissions @@ -154,11 +148,11 @@ func resolveRolePerms(p *Policy, role, table, operation string) (perms RolePermi case "insert": rolePerms = tp.Insert default: - return RolePermissions{}, false, false + return &ResolvedPermissions{Allowed: false} } if rolePerms == nil { - return RolePermissions{}, false, false + return &ResolvedPermissions{Allowed: false} } // An empty/absent role must never match a role entry — a roleless request is @@ -167,18 +161,9 @@ func resolveRolePerms(p *Policy, role, table, operation string) (perms RolePermi // role key in a policy therefore grants nothing: the policy-side twin of the // empty-AllowedRoles-entry footgun closed for pipes in #159. Matching is // exact — there is no "*" any-role wildcard. - if role == "" { - return RolePermissions{}, false, false - } - perms, ok = rolePerms[role] - return perms, false, ok -} - -// Evaluate resolves a policy for a given role, table, and operation against JWT claims. -func Evaluate(p *Policy, role, table, operation string, claims map[string]any) *ResolvedPermissions { - perms, admin, ok := resolveRolePerms(p, role, table, operation) - if admin { - return &ResolvedPermissions{Allowed: true} + perms, ok := RolePermissions{}, false + if role != "" { + perms, ok = rolePerms[role] } if !ok { return &ResolvedPermissions{Allowed: false} @@ -196,16 +181,24 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * MaxMemoryUsage: perms.MaxMemoryUsage, } - // Resolve the row-filter once into predicates, then render both read surfaces - // from that single source so they can't drift: the query path binds them into a - // SQL WHERE here; the stream path evaluates the same compiled predicates in memory - // (CompiledRowFilter.RowVisible). resolveFilterPredicates fails closed on a - // bind-unsafe column (see there). + // Resolve filters into WHERE clause. A bind-unsafe filter column can't be + // emitted safely — a '?' in it would shift clickhouse-go's positional value + // binding, including this RLS filter's own bound value — so deny the role + // fail-closed rather than drop the predicate (which would widen row access) + // or emit a mis-bound query. validateRolePerms rejects such a policy at write + // time; this guards the query path as defense-in-depth (Evaluate does not + // re-validate the policy it is handed). if len(perms.Filter) > 0 { - preds, deny := resolveFilterPredicates(perms.Filter, claims) - if deny { - return &ResolvedPermissions{Allowed: false} + for col := range perms.Filter { + if chsql.BindUnsafe(col) { + return &ResolvedPermissions{Allowed: false} + } } + // Resolve the row-filter once into predicates, then render both read surfaces + // from that single source so they can't drift: the query path binds them into + // a SQL WHERE here; the stream path evaluates the same predicates in memory + // (ResolvedPermissions.RowVisible). + preds := resolvePredicates(perms.Filter, claims) resolved.rowFilter = preds clauses, params := predicatesToSQL(preds) if len(clauses) > 0 { @@ -232,234 +225,42 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * return resolved } -// resolveFilterPredicates resolves a role's row-filter map into predicates for the -// query path, first failing closed (deny=true) if any filter column is bind-unsafe: a -// '?' in the column would shift clickhouse-go's positional value binding — including -// this filter's own bound value — so the role is denied rather than the predicate -// dropped (which would widen row access) or a mis-bound query emitted. validateRolePerms -// rejects such a policy at write time; this is defense-in-depth (the resolver does not -// re-validate the policy it is handed). The stream path applies the same bind-unsafe -// gate in CompileRowFilter, so both read surfaces reject the same unsafe policy. -func resolveFilterPredicates(filters map[string]Filter, claims map[string]any) (preds []resolvedPredicate, deny bool) { - if filterBindUnsafe(filters) { - return nil, true - } - return resolvePredicates(filters, claims), false -} - -// filterBindUnsafe reports whether any filter column can't be bound safely (see -// resolveFilterPredicates for why that denies the role). Claims-independent, so the -// stream can check it once per bucket in CompileRowFilter. -func filterBindUnsafe(filters map[string]Filter) bool { - for col := range filters { - if chsql.BindUnsafe(col) { - return true - } - } - return false -} - -// ----------------------------------------------------------------------------- -// Compiled row filter: claims-independent structure, per-subscriber claim binding. -// -// The stream fan-out resolves a role's row-filter ONCE per bucket into compiled -// predicates (CompileRowFilter), then binds each subscriber's claims at evaluation -// time (CompiledRowFilter.RowVisible). It is the deferred-binding twin of -// resolvePredicates, which eagerly resolves for the query path's single claim set. -// The two must agree on every (filter, claims) pair — that parity is pinned by -// TestCompiledRowFilter_MatchesEvaluatePath. The optimization is that a whole -// {{ jwt.path }} value binds via a map walk (navigateClaims) and a constant binds to -// itself, so a filtered high-fan-out topic pays no per-subscriber regexp/predicate -// allocation. -// ----------------------------------------------------------------------------- - -type valueKind uint8 - -const ( - valConstant valueKind = iota // literal, no claim template - valClaim // exactly one {{ jwt.path }} — bound via navigateClaims - valTemplate // text with embedded {{ jwt.… }} — bound via resolveTemplate -) - -// compiledValue is a filter value with its claim-template structure classified once, -// so binding a subscriber's claims is a map walk (valClaim) or a no-op (valConstant) -// rather than a regexp pass. -type compiledValue struct { - kind valueKind - s string // valConstant literal, or valTemplate raw text - path []string // valClaim: the pre-split jwt claim path -} - -// compileScalarValue classifies an _eq/_neq/_gt/_lt value the way resolveTemplate reads -// it — a substring replace, with a whole-value (no surrounding text) {{ jwt.path }} -// short-circuited to a direct claim lookup. It matches on the UNtrimmed value so a -// space-padded ref stays a template, exactly as resolveTemplate would keep the spaces. -func compileScalarValue(v string) compiledValue { - if m := wholeClaimRe.FindStringSubmatch(v); m != nil { - return compiledValue{kind: valClaim, path: strings.Split(m[1], ".")} - } - if claimTemplateRe.MatchString(v) { - return compiledValue{kind: valTemplate, s: v} - } - return compiledValue{kind: valConstant, s: v} -} - -// compileInValue classifies an _in value the way resolveInValues reads it: a -// TrimSpace'd whole {{ jwt.path }} is a claim list; anything else is a single -// (possibly templated) value. -func compileInValue(v string) compiledValue { - if m := wholeClaimRe.FindStringSubmatch(strings.TrimSpace(v)); m != nil { - return compiledValue{kind: valClaim, path: strings.Split(m[1], ".")} - } - if claimTemplateRe.MatchString(v) { - return compiledValue{kind: valTemplate, s: v} - } - return compiledValue{kind: valConstant, s: v} -} - -// bindScalar resolves a scalar value against claims — the deferred twin of -// resolveTemplate. A whole-claim ref returns the claim's string form (no allocation -// when the claim is already a string; fmt.Sprint otherwise, matching resolveTemplate), -// and a missing claim resolves to "" exactly as resolveTemplate does. -func (cv compiledValue) bindScalar(claims map[string]any) string { - switch cv.kind { - case valClaim: - val := navigateClaims(claims, cv.path) - if val == nil { - return "" - } - if s, ok := val.(string); ok { - return s - } - return fmt.Sprint(val) - case valTemplate: - return resolveTemplate(cv.s, claims) - case valConstant: - return cv.s - default: - return cv.s // unreachable: every valueKind has an explicit case above - } -} - -// bindIn resolves an _in value against claims into the set of bound values (the -// deferred twin of resolveInValues). A claim list yields one bound value per element; -// any other value yields the single scalar-bound value. -func (cv compiledValue) bindIn(claims map[string]any) []string { - if cv.kind == valClaim { - switch v := navigateClaims(claims, cv.path).(type) { - case nil: - return nil - case []any: - out := make([]string, 0, len(v)) - for _, e := range v { - out = append(out, fmt.Sprint(e)) - } - return out - default: - return []string{fmt.Sprint(v)} - } - } - return []string{cv.bindScalar(claims)} -} - -// compiledPredicate is one row-filter comparison with its value structure compiled but -// the claim binding deferred to evaluation time. -type compiledPredicate struct { +// resolvedPredicate is one row-filter comparison with its claim templates already +// resolved to concrete string values — the shared, render-agnostic form the query +// path turns into SQL (predicatesToSQL) and the stream path evaluates in memory +// (RowVisible). Op is one of "=", "!=", ">", "<", "in". Values holds one element +// for the scalar operators and zero-or-more for "in" (empty ⇒ matches no rows). +type resolvedPredicate struct { Column string Op string - Value compiledValue + Values []string } -// compilePredicates compiles a role's row-filter into per-subscriber-bindable -// predicates, mirroring resolvePredicates' column/operator expansion (and order) so the -// compiled and resolved paths produce the same predicate set. -func compilePredicates(filters map[string]Filter) []compiledPredicate { - var preds []compiledPredicate +// resolvePredicates resolves each filter's claim templates once into predicates. +// Both read surfaces derive from this single result so they can't drift; the +// operator order within a column (=, !=, >, <, in) mirrors the former inline SQL. +func resolvePredicates(filters map[string]Filter, claims map[string]any) []resolvedPredicate { + var preds []resolvedPredicate for col, f := range filters { if f.Eq != nil { - preds = append(preds, compiledPredicate{col, "=", compileScalarValue(*f.Eq)}) + preds = append(preds, resolvedPredicate{col, "=", []string{resolveTemplate(*f.Eq, claims)}}) } if f.Neq != nil { - preds = append(preds, compiledPredicate{col, "!=", compileScalarValue(*f.Neq)}) + preds = append(preds, resolvedPredicate{col, "!=", []string{resolveTemplate(*f.Neq, claims)}}) } if f.Gt != nil { - preds = append(preds, compiledPredicate{col, ">", compileScalarValue(*f.Gt)}) + preds = append(preds, resolvedPredicate{col, ">", []string{resolveTemplate(*f.Gt, claims)}}) } if f.Lt != nil { - preds = append(preds, compiledPredicate{col, "<", compileScalarValue(*f.Lt)}) + preds = append(preds, resolvedPredicate{col, "<", []string{resolveTemplate(*f.Lt, claims)}}) } if f.In != nil { - preds = append(preds, compiledPredicate{col, "in", compileInValue(*f.In)}) + preds = append(preds, resolvedPredicate{col, "in", toStrings(resolveInValues(*f.In, claims))}) } } return preds } -// CompiledRowFilter is a role/table's row-level-security filter compiled once -// (claims-independent) so the stream fan-out can reuse it across every subscriber in a -// bucket: resolve it per bucket with CompileRowFilter, then call RowVisible per -// subscriber with that subscriber's claims. It binds claims at evaluation time, so a -// filtered high-fan-out topic pays no per-subscriber predicate resolution — the -// deferred-binding counterpart to the query path's resolvePredicates (which binds a -// single claim set eagerly for SQL). -type CompiledRowFilter struct { - allowed bool - preds []compiledPredicate -} - -// CompileRowFilter compiles the row-filter governing (role, table, operation), -// claims-independently. It shares Evaluate's role resolution, admin bypass, and -// default-deny (via resolveRolePerms) and the same bind-unsafe gate, so it agrees with -// the query path on who is authorized and which policy is rejected. Compile once per -// role bucket; the per-subscriber cost is then only RowVisible. -func CompileRowFilter(p *Policy, role, table, operation string) *CompiledRowFilter { - perms, admin, ok := resolveRolePerms(p, role, table, operation) - if admin { - return &CompiledRowFilter{allowed: true} // admin: no predicates ⇒ every row visible - } - if !ok { - return &CompiledRowFilter{allowed: false} - } - if filterBindUnsafe(perms.Filter) { - return &CompiledRowFilter{allowed: false} // fail closed, as Evaluate does - } - return &CompiledRowFilter{allowed: true, preds: compilePredicates(perms.Filter)} -} - -// resolvedPredicate is one row-filter comparison with its claim templates already -// resolved to concrete string values — the shared, render-agnostic form the query -// path turns into SQL (predicatesToSQL) and the stream path evaluates in memory -// (RowVisible). Op is one of "=", "!=", ">", "<", "in". Values holds one element -// for the scalar operators and zero-or-more for "in" (empty ⇒ matches no rows). -type resolvedPredicate struct { - Column string - Op string - Values []string -} - -// resolvePredicates resolves each filter's claim templates into predicates for the -// query path. It compiles the filter — the single, claims-independent source the stream -// path also uses (CompileRowFilter) — then binds this request's claims, so the two read -// surfaces derive from one place and can't drift. Operator order within a column -// (=, !=, >, <, in) mirrors compilePredicates and the former inline SQL. -func resolvePredicates(filters map[string]Filter, claims map[string]any) []resolvedPredicate { - var preds []resolvedPredicate - for _, cp := range compilePredicates(filters) { - preds = append(preds, cp.resolve(claims)) - } - return preds -} - -// resolve binds a compiled predicate's claim value(s) into a resolvedPredicate — the -// eager, query-path counterpart to compiledPredicate.visible (which the stream -// evaluates against the row in memory instead of materializing this form). -func (cp compiledPredicate) resolve(claims map[string]any) resolvedPredicate { - if cp.Op == "in" { - return resolvedPredicate{cp.Column, "in", cp.Value.bindIn(claims)} - } - return resolvedPredicate{cp.Column, cp.Op, []string{cp.Value.bindScalar(claims)}} -} - // predicatesToSQL renders resolved predicates into WHERE clauses and bound params. func predicatesToSQL(preds []resolvedPredicate) ([]string, []any) { var clauses []string @@ -497,6 +298,19 @@ func resolveFilters(filters map[string]Filter, claims map[string]any) ([]string, return predicatesToSQL(resolvePredicates(filters, claims)) } +// toStrings normalizes resolveInValues' []any (already stringified elements) to the +// []string a resolvedPredicate carries. +func toStrings(vals []any) []string { + if len(vals) == 0 { + return nil + } + out := make([]string, len(vals)) + for i, v := range vals { + out[i] = fmt.Sprint(v) + } + return out +} + // resolveTemplate resolves {{ jwt.claim.path }} templates against JWT claims. // If a claim path cannot be resolved, the template placeholder is replaced with // an empty string to prevent "" from leaking into SQL filters. diff --git a/internal/policy/rowfilter.go b/internal/policy/rowfilter.go index 92101851..b008165b 100644 --- a/internal/policy/rowfilter.go +++ b/internal/policy/rowfilter.go @@ -37,6 +37,11 @@ func (p *ResolvedPermissions) RowVisible(row map[string]any, numericCols map[str if p == nil { return true } + // A denied role sees no rows — fail closed, mirroring the !Allowed guard on + // IsColumnAllowed, so a denied receiver never reads as "no filter ⇒ all visible". + if !p.Allowed { + return false + } for _, pred := range p.rowFilter { if !pred.matches(row, numericCols[pred.Column]) { return false @@ -77,69 +82,6 @@ func (pred resolvedPredicate) matches(row map[string]any, numeric bool) bool { } } -// HasRowFilter reports whether the compiled filter carries any predicate. A nil -// receiver, or an admin / unfiltered role, has none — the compiled twin of -// ResolvedPermissions.HasRowFilter. -func (c *CompiledRowFilter) HasRowFilter() bool { - return c != nil && len(c.preds) > 0 -} - -// RowVisible reports whether row satisfies every compiled predicate for the given -// claims — the per-subscriber, deferred-binding twin of ResolvedPermissions.RowVisible, -// and it must agree with it on every decision. A nil receiver (no policy applies) -// admits every row; a denied or bind-unsafe filter admits none (fail closed). -// numericCols is as in ResolvedPermissions.RowVisible. -func (c *CompiledRowFilter) RowVisible(row, claims map[string]any, numericCols map[string]bool) bool { - if c == nil { - return true - } - if !c.allowed { - return false - } - for _, pred := range c.preds { - if !pred.visible(row, claims, numericCols[pred.Column]) { - return false - } - } - return true -} - -// visible evaluates one compiled predicate against the row for the given claims. It -// binds the claim value at call time, then reuses compareScalar — the same comparison -// core as resolvedPredicate.matches — so the compiled and resolved paths can't drift on -// how values compare. Fails closed on an absent or uncomparable value, exactly as -// matches does. -func (cp compiledPredicate) visible(row, claims map[string]any, numeric bool) bool { - raw, ok := row[cp.Column] - if !ok { - return false // column not in the event ⇒ can't prove the row is allowed - } - if cp.Op == "in" { - for _, v := range cp.Value.bindIn(claims) { - if c, ok := compareScalar(raw, v, numeric); ok && c == 0 { - return true - } - } - return false - } - c, ok := compareScalar(raw, cp.Value.bindScalar(claims), numeric) - if !ok { - return false - } - switch cp.Op { - case "=": - return c == 0 - case "!=": - return c != 0 - case ">": - return c > 0 - case "<": - return c < 0 - default: - return false - } -} - // compareScalar compares an event value against a resolved filter value, returning // -1/0/+1 and ok=false when the comparison can't be made (a non-scalar event value, // or a numeric comparison whose operands don't parse as numbers). numeric selects diff --git a/internal/policy/rowfilter_test.go b/internal/policy/rowfilter_test.go index 8c7a804f..6e6103cc 100644 --- a/internal/policy/rowfilter_test.go +++ b/internal/policy/rowfilter_test.go @@ -1,11 +1,9 @@ package policy import ( - "strings" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // evalRowFilter builds a one-role/one-table policy carrying filter and returns the @@ -141,126 +139,3 @@ func TestRowVisible_MultiplePredicates_AllMustPass(t *testing.T) { assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "acme", "amount": float64(9)}, num), "amount fails") assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "globex", "amount": float64(250)}, num), "tenant fails") } - -// TestCompiledRowFilter_MatchesEvaluatePath pins the parity the compiled per-subscriber -// stream path depends on: for every (filter, claims, row) below, CompileRowFilter + -// RowVisible must return exactly what the query path (Evaluate → resolved predicates → -// matches) returns. Were the two to diverge, a subscriber could see rows the query path -// hides (or vice versa) — the -// row-level-security bug this path exists to prevent. The matrix exercises every operator, -// constant / whole-claim / mixed-template / space-padded / nested-path values, and -// missing columns, empty claim sets, and numeric-vs-lexicographic comparison. -func TestCompiledRowFilter_MatchesEvaluatePath(t *testing.T) { - t.Parallel() - filters := []map[string]Filter{ - {"tenant_id": {Eq: new("{{ jwt.tenant }}")}}, - {"tenant_id": {Neq: new("{{ jwt.tenant }}")}}, - {"amount": {Gt: new("100")}}, - {"amount": {Lt: new("{{ jwt.cap }}")}}, - {"status": {Eq: new("active")}}, // constant - {"region": {In: new("{{ jwt.regions }}")}}, // claim list - {"tag": {In: new("{{ jwt.tag }}")}}, // claim scalar - {"kind": {In: new("published")}}, // constant in-set - {"label": {Eq: new("v-{{ jwt.tier }}")}}, // mixed template - {"tenant_id": {Eq: new(" {{ jwt.tenant }} ")}}, // space-padded ⇒ stays a template - {"tenant_id": {Eq: new("{{ jwt.tenant }}"), Neq: new("blocked")}}, // two predicates on one column - {"org": {Eq: new("{{ jwt.org.id }}")}}, // nested claim path - } - claimsets := []map[string]any{ - nil, - {"tenant": "acme"}, - {"tenant": "acme", "cap": float64(500), "regions": []any{"us", "eu"}, "tag": "x", "tier": "pro", "org": map[string]any{"id": "o1"}}, - {"tenant": ""}, - {"tenant": "acme", "regions": []any{}}, // empty claim list - } - rows := []map[string]any{ - {"tenant_id": "acme", "amount": float64(250), "status": "active", "region": "us", "tag": "x", "kind": "published", "label": "v-pro", "org": "o1"}, - {"tenant_id": "globex", "amount": float64(9), "status": "off", "region": "apac", "tag": "y", "kind": "draft", "label": "v-free", "org": "o2"}, - {"amount": float64(500)}, // several columns missing ⇒ fail closed - {"tenant_id": "", "amount": "NaN", "org": "o1"}, - } - numeric := map[string]bool{"amount": true} - - for fi, f := range filters { - p := &Policy{Tables: map[string]TablePolicy{ - "t": {Select: map[string]RolePermissions{"viewer": {Filter: f}}}, - }} - compiled := CompileRowFilter(p, "viewer", "t", "select") - assert.Equalf(t, Evaluate(p, "viewer", "t", "select", nil).HasRowFilter(), compiled.HasRowFilter(), - "HasRowFilter parity, filter#%d", fi) - for _, claims := range claimsets { - resolved := Evaluate(p, "viewer", "t", "select", claims) - for ri, row := range rows { - want := resolved.RowVisible(row, numeric) - got := compiled.RowVisible(row, claims, numeric) - assert.Equalf(t, want, got, - "filter#%d claims=%v row#%d — compiled=%v resolved=%v", fi, claims, ri, got, want) - } - } - } -} - -// FuzzCompiledRowFilterParity is the property form of the matrix test above: for ANY -// operator, filter value, claim, and row the fuzzer synthesizes, the compiled -// per-subscriber stream path (CompileRowFilter + RowVisible) must agree with the query -// path (Evaluate + resolved predicates). The fuzzer probes the template-parsing edges -// where a divergence would hide — stray "{{", "jwt.", nested dots, whitespace, partial -// templates — that a hand-written matrix can't enumerate. The seed corpus also runs as a -// plain regression test under `go test` (no -fuzz needed). The column is fixed to a -// bind-safe name so the role always resolves as allowed; RowVisible parity is defined -// only for an allowed role (a denied role never reaches the per-subscriber check). -func FuzzCompiledRowFilterParity(f *testing.F) { - f.Add(uint8(0), "{{ jwt.x }}", "acme", "acme", true, false, false) // eq claim-ref, match - f.Add(uint8(1), " {{ jwt.x }} ", "acme", "acme", true, false, false) // space-padded ⇒ template - f.Add(uint8(2), "100", "", "250", true, true, false) // gt numeric constant - f.Add(uint8(3), "{{ jwt.x }}", "500", "250", true, true, false) // lt numeric claim - f.Add(uint8(4), "{{ jwt.x }}", "a,b,c", "b", true, false, true) // in claim-list - f.Add(uint8(0), "v-{{ jwt.x }}", "pro", "v-pro", true, false, false) // mixed template - f.Add(uint8(0), "{{ jwt.x }}", "acme", "acme", false, false, false) // column absent ⇒ fail closed - f.Add(uint8(0), "{{ jwt.a.b }}", "z", "z", true, false, false) // nested claim path - - f.Fuzz(func(t *testing.T, opSel uint8, filterVal, claimVal, rowVal string, colPresent, numeric, claimList bool) { - var filter Filter - switch opSel % 5 { - case 0: - filter.Eq = &filterVal - case 1: - filter.Neq = &filterVal - case 2: - filter.Gt = &filterVal - case 3: - filter.Lt = &filterVal - case 4: - filter.In = &filterVal - } - p := &Policy{Tables: map[string]TablePolicy{ - "t": {Select: map[string]RolePermissions{"r": {Filter: map[string]Filter{"col": filter}}}}, - }} - - var claim any = claimVal - if claimList { // exercise the _in []any path, not just a scalar claim - parts := strings.Split(claimVal, ",") - list := make([]any, len(parts)) - for i, s := range parts { - list[i] = s - } - claim = list - } - claims := map[string]any{"x": claim} - row := map[string]any{} - if colPresent { - row["col"] = rowVal - } - nc := map[string]bool{} - if numeric { - nc["col"] = true - } - - compiled := CompileRowFilter(p, "r", "t", "select") - resolved := Evaluate(p, "r", "t", "select", claims) - require.True(t, resolved.Allowed, "fixed bind-safe setup must resolve as allowed") - require.Equalf(t, resolved.RowVisible(row, nc), compiled.RowVisible(row, claims, nc), - "parity broke: op=%d filter=%q claim=%v(list=%v) row=%q(present=%v) numeric=%v", - opSel%5, filterVal, claim, claimList, rowVal, colPresent, numeric) - }) -} diff --git a/internal/stream/hub.go b/internal/stream/hub.go index c8af8390..934556f4 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -19,7 +19,7 @@ import ( // resolved against each subscriber's claims, so two subscribers of the same role can // be entitled to different rows. For a role that carries a row-filter, Broadcast // therefore keeps the shared column projection but evaluates row visibility PER -// subscriber (CompiledRowFilter.RowVisible) before delivering — closing the +// subscriber (ResolvedPermissions.RowVisible) before delivering — closing the // query/stream RLS drift in #319. Roles without a row-filter keep the pure // once-per-role fast path unchanged. See projectColumns. type Hub struct { @@ -161,16 +161,13 @@ func (h *Hub) Broadcast(topic string, raw []byte) { // shared, but whether each subscriber may see THIS row depends on its claims, so // evaluate visibility per subscriber. Predicates read the full event data (a // filter may key on a column the role can't SELECT), not the projected columns. - // The filter compiles once per bucket (claims-independent); only the claim - // binding inside RowVisible is per subscriber, so a filtered high-fan-out topic - // pays no per-subscriber predicate resolution. if !numericResolved { numericCols = h.numericCols(evt.TableName) numericResolved = true } - compiled := policy.CompileRowFilter(p, rb.role, evt.TableName, "select") for _, sub := range rb.bucket.Snapshot() { - if !compiled.RowVisible(evt.Data, sub.claims, numericCols) { + subPerms := policy.Evaluate(p, rb.role, evt.TableName, "select", sub.claims) + if !subPerms.RowVisible(evt.Data, numericCols) { continue // this row is filtered out for this subscriber } if !sub.Send(frame) { @@ -227,8 +224,8 @@ func (h *Hub) ReplayFrame(role string, claims map[string]any, raw []byte) (Frame return Frame{}, false } if perms.HasRowFilter() { - compiled := policy.CompileRowFilter(p, role, evt.TableName, "select") - if !compiled.RowVisible(evt.Data, claims, h.numericCols(evt.TableName)) { + subPerms := policy.Evaluate(p, role, evt.TableName, "select", claims) + if !subPerms.RowVisible(evt.Data, h.numericCols(evt.TableName)) { return Frame{}, false // this row is filtered out for these claims } } @@ -241,9 +238,8 @@ func (h *Hub) ReplayFrame(role string, claims map[string]any, raw []byte) (Frame // role+table policy entry (AllowColumns/DenyColumns), never from claims, so this // frame is byte-identical for every subscriber of a (role, table) — the shared // projection that lets one serialization serve a whole bucket. Row-level security is -// NOT applied here; when perms.HasRowFilter(), the caller compiles the role's filter -// once (policy.CompileRowFilter) and checks its RowVisible per subscriber with that -// subscriber's claims. ok=false means skip: the role can't +// NOT applied here; the caller checks perms.RowVisible per subscriber (with that +// subscriber's claims) when perms.HasRowFilter(). ok=false means skip: the role can't // read the table, or the payload is unusable. perms is nil for the legacy no-policy // passthrough (which has no row-filter). func projectColumns(p *policy.Policy, filter bool, role string, evt *ingest.EventMessage, raw []byte, decoded bool) (wire []byte, perms *policy.ResolvedPermissions, ok bool) { diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index 130b1765..f07d92cf 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -3,7 +3,6 @@ package stream import ( "context" "encoding/json" - "fmt" "strings" "sync" "testing" @@ -19,9 +18,8 @@ import ( "go.opentelemetry.io/otel/sdk/metric/metricdata" ) -// rawEvent marshals an EventMessage the way the ingest path publishes it. It takes -// testing.TB so both tests (*testing.T) and benchmarks (*testing.B) can build events. -func rawEvent(t testing.TB, table, ts string, data map[string]any) []byte { +// rawEvent marshals an EventMessage the way the ingest path publishes it. +func rawEvent(t *testing.T, table, ts string, data map[string]any) []byte { t.Helper() raw, err := json.Marshal(ingest.EventMessage{TableName: table, ReceivedTimestamp: ts, Data: data}) require.NoError(t, err) @@ -506,45 +504,6 @@ func TestWireFrame(t *testing.T) { } } -// BenchmarkBroadcast_RowFilteredFanout measures one Broadcast on a row-filtered topic -// as the subscriber count grows. The filter compiles once per bucket -// (policy.CompileRowFilter); each subscriber then pays only a claim-bound RowVisible on -// the full event, so allocations stay flat regardless of fan-out. This is the benchmark -// behind the O(subscribers) → O(1) allocation work on PR #381 — run it before/after a -// fan-out change to catch a regression back to per-subscriber resolution. It isolates -// the fan-out cost: the shared column projection is built once per event, and full -// outbound queues merely drop (a nil-metric no-op). -// -// go test ./internal/stream/ -run '^$' -bench BenchmarkBroadcast_RowFilteredFanout -benchmem -func BenchmarkBroadcast_RowFilteredFanout(b *testing.B) { - const topic = "ingest.clicks" - raw := rawEvent(b, "clicks", "2026-06-26T00:00:00Z", - map[string]any{"tenant_id": "acme", "page": "/a", "secret": "x"}) - - for _, n := range []int{100, 1_000, 10_000} { - b.Run(fmt.Sprintf("subscribers=%d", n), func(b *testing.B) { - hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) - // Half the subscribers share the event's tenant (row visible), half don't - // (row withheld); either way each pays the per-subscriber RowVisible check - // against the once-per-bucket compiled filter, which is the cost under - // measurement. - for i := range n { - sub := NewSubscriber() - tenant := "acme" - if i%2 == 1 { - tenant = "globex" - } - sub.SetClaims(map[string]any{"tenant": tenant}) - hub.Add(topic, "viewer", sub) - } - b.ReportAllocs() - for b.Loop() { - hub.Broadcast(topic, raw) - } - }) - } -} - // sumByName totals all datapoints of an Int64 sum instrument across kinds. func sumByName(rm metricdata.ResourceMetrics, name string) int64 { for _, sm := range rm.ScopeMetrics { diff --git a/tests/e2e/sdk/streaming.test.ts b/tests/e2e/sdk/streaming.test.ts index 1dc58e0f..2a1c9314 100644 --- a/tests/e2e/sdk/streaming.test.ts +++ b/tests/e2e/sdk/streaming.test.ts @@ -28,7 +28,7 @@ describe("Streaming", () => { // Explicitly allow the 'anon' role to SELECT (stream) from this suite's tables. // 'scoped' additionally carries a per-subscriber row filter — streamed rows are // limited to the caller's own country claim — so the SSE fan-out exercises the - // row-level-security path (CompileRowFilter → RowVisible) end to end, not just + // row-level-security path (ResolvedPermissions.RowVisible) end to end, not just // column projection. publicPolicy.tables[T.clicks].select = { ...(publicPolicy.tables[T.clicks].select || {}), From 4d9273fcc3a8b6031210d902cd2ebde716967d03 Mon Sep 17 00:00:00 2001 From: taitelee Date: Tue, 11 Aug 2026 12:46:37 -0400 Subject: [PATCH 10/27] deps: bump grpc, x/text, compress to clear govulncheck advisories --- go.mod | 22 +++++++++++----------- go.sum | 44 ++++++++++++++++++++++---------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/go.mod b/go.mod index 0ea6370e..e7d3c557 100644 --- a/go.mod +++ b/go.mod @@ -46,8 +46,8 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 go.opentelemetry.io/proto/otlp v1.10.0 - golang.org/x/sync v0.21.0 - google.golang.org/grpc v1.81.1 + golang.org/x/sync v0.22.0 + google.golang.org/grpc v1.83.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -131,7 +131,7 @@ require ( github.com/jedib0t/go-pretty/v6 v6.7.10 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/joho/godotenv v1.5.1 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.19.1 // indirect github.com/knadh/profiler v0.2.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect @@ -196,17 +196,17 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.26.0 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/image v0.38.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/tools v0.47.0 // indirect golang.org/x/vuln v1.3.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect diff --git a/go.sum b/go.sum index d34ebedc..aa90027d 100644 --- a/go.sum +++ b/go.sum @@ -221,8 +221,8 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/knadh/profiler v0.2.0 h1:jaY0xlQs8iaWxKdvGHOftaZnX7d8l7yrCGQPSecwnng= github.com/knadh/profiler v0.2.0/go.mod h1:LqNkAu++MfFkbEDA63AmRaIf6UkGrLXyZ5VQQdekZiI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -439,29 +439,29 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -475,27 +475,27 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e h1:OXgN37M6hqjaAvb7CJK9vJ+7Z/6lvIm5bXho5poo/Wk= -golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -512,8 +512,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 2bc01ac7c80b6e356143675045236b10d9cedb58 Mon Sep 17 00:00:00 2001 From: taitelee Date: Tue, 11 Aug 2026 12:46:37 -0400 Subject: [PATCH 11/27] fix(policy): fail row-filter closed on unprovable comparisons; decode big integers exactly --- AGENTS.md | 4 +- CHANGELOG.md | 3 +- SECURITY.md | 2 +- docs/src/content/docs/access-control.mdx | 13 +- docs/src/content/docs/api.md | 2 +- docs/src/content/docs/architecture.md | 22 ++- internal/api/stream.go | 6 +- internal/discovery/validation.go | 14 +- internal/discovery/validation_test.go | 31 ++++ internal/policy/rowfilter.go | 116 ++++++++---- internal/policy/rowfilter_test.go | 78 ++++++-- internal/stream/bucket_test.go | 6 +- internal/stream/doc.go | 5 +- internal/stream/heartbeat_test.go | 10 +- internal/stream/hub.go | 124 ++++++++----- internal/stream/hub_test.go | 218 ++++++++++++++++++++--- internal/stream/metrics.go | 20 ++- internal/stream/subscriber.go | 27 +-- internal/stream/subscriber_test.go | 2 +- 19 files changed, 545 insertions(+), 158 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1e3ec198..6b188953 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Fourteen internal packages under `internal/` (plus `internal/testutil/` for shar - **`pipes/`** — Named query pipes: `NamedQuery` type + NATS KV store (`WAVEHOUSE_PIPES`) + `.sql` file bootstrap - **`policy/`** — Hasura-style access control: `Policy`/`TablePolicy`/`RolePermissions` types, `Evaluate()` engine with JWT claim templating, NATS KV store (`WAVEHOUSE_POLICY`) - **`query/`** — Structured query AST types + SQL builder with schema validation, permission injection, timestamp bucketing -- **`stream/`** — SSE fan-out: the event `Hub` (registers subscribers by `(topic, role)`; `Broadcast` projects + serializes each event once per role, the #294 delivery hot path), `Subscriber` (per-connection outbound `Frame` queue, `Send`/`Frames`), the `Bucket` fan-out set (`subscriberSet`, one per `(topic, role)`), the `Heartbeater` keepalive wheel, and `Metrics` (the `wavehouse_sse_*` stream instruments) +- **`stream/`** — SSE fan-out: the event `Hub` (registers subscribers by `(topic, role)`; `Broadcast` projects + serializes each event once per role, the #294 delivery hot path — a role carrying a row-level `filter` keeps the shared projection but delivers per subscriber, each subscriber's claims evaluated against the row, #319), `Subscriber` (per-connection outbound `Frame` queue, `Send`/`Frames`; claims fixed at construction, immutable), the `Bucket` fan-out set (`subscriberSet`, one per `(topic, role)`), the `Heartbeater` keepalive wheel, and `Metrics` (the `wavehouse_sse_*` stream instruments) ## Key Design Decisions @@ -58,7 +58,7 @@ The invariant index — what must stay true. Full narrative and rationale live i 9. **Singleflight** — `TieredCache` coalesces concurrent misses (`x/sync/singleflight`) to prevent cache stampede. 10. **Active Sweeper** — purges NATS messages that are both ACKed (written to CH) and older than the gap window; SSE gap-fill uses `DeliverByStartTime`, no in-process ring buffer. 11. **Hasura-style access control: fail-closed (security)** — `policy.IsAdmin` (role == `admin_role`, **exact case-sensitive**, default `"admin"`) is the single admin check, shared by `Evaluate`/`ResolveRole`/`Validate`/the `/v1/admin` gate/`RoleAllowed`. Empty/absent role matches nothing (no `"*"` wildcard); `Validate` rejects empty role keys; a `nil` policy (deleted) denies **everyone incl. admin** via a role — a total lockout for token-based callers, so bootstrap from the policy file, never an implicit admin grant (**exception:** the operator key's `auth.IsOperator` bit passes the `/v1/admin` gate even under a `nil` policy — a deliberate break-glass restore over HTTP, see #7). `default_role` is the one sanctioned roleless exception (`ResolveRole` maps empty → it pre-eval); `default_role == admin_role` is permitted but dev-only and loudly warned (`policy.DefaultRoleGrantsAdmin`). Preserve when touching `internal/policy` (policy twin of #13; see #159). Detail: architecture.md § `policy/`. -12. **Structured queries: column authz fail-closed (security)** — `POST /v1/query?table={table}`: typed AST validated against schema, permission-enforced, timestamp-bucketed for cache, `DefaultMaxRows` (10,000) cap. Every column reference — projection, aggregation args, `filters`, `group_by`, `order_by`, `time_range` — is authorized inside `query.Build` (the single chokepoint that enumerates them all), so no clause can skip the role's `allow_columns`/`deny_columns` check (#223). A `select_all` read by a *column-restricted* role expands to its allowed columns via `policy.AllowedProjection`, never a bare `SELECT *`; *unrestricted*/admin roles keep `SELECT *` (`policy.RestrictsColumns` decides). Omitting `columns` selects nothing (`ErrEmptyProjection` → `200 []`); `["*"]` is the literal column `*` (schema-gated, not a wildcard); a table-granted role with no readable columns fails closed (`ErrNoReadableColumns` → `403`). Structured and live-stream (`stream.filterColumns`) reads share the one per-column decision `policy.IsColumnAllowed`, so column visibility can't drift. Preserve when touching `internal/query` or the structured-query handler. Detail: architecture.md § `query/`. +12. **Structured queries: column authz fail-closed (security)** — `POST /v1/query?table={table}`: typed AST validated against schema, permission-enforced, timestamp-bucketed for cache, `DefaultMaxRows` (10,000) cap. Every column reference — projection, aggregation args, `filters`, `group_by`, `order_by`, `time_range` — is authorized inside `query.Build` (the single chokepoint that enumerates them all), so no clause can skip the role's `allow_columns`/`deny_columns` check (#223). A `select_all` read by a *column-restricted* role expands to its allowed columns via `policy.AllowedProjection`, never a bare `SELECT *`; *unrestricted*/admin roles keep `SELECT *` (`policy.RestrictsColumns` decides). Omitting `columns` selects nothing (`ErrEmptyProjection` → `200 []`); `["*"]` is the literal column `*` (schema-gated, not a wildcard); a table-granted role with no readable columns fails closed (`ErrNoReadableColumns` → `403`). Structured and live-stream (`stream.filterColumns`) reads share the one per-column decision `policy.IsColumnAllowed`, so column visibility can't drift. Row visibility has the same one-source guarantee (#319): `Evaluate` resolves a role's row-`filter` once (`resolvePredicates`), and both surfaces consume that single resolution — the query path renders it to SQL (`predicatesToSQL`), the stream evaluates it in memory per subscriber (`ResolvedPermissions.RowVisible`, whose type-aware comparison fails closed on anything it can't prove about the ingested payload — `policy.ColumnKind`; insert-time numeric narrowing is the one residual payload-vs-stored asymmetry, documented in the access-control enforcement caution) — so row visibility can't drift either. Preserve when touching `internal/query` or the structured-query handler. Detail: architecture.md § `query/`. 13. **Named query pipes: fail-closed (security)** — pre-defined SQL templates (Tinybird-style) with param binding + caching; `GET/POST /v1/pipes/{name}` sit outside `RequireAdmin`, so per-pipe `allowed_roles` is the *only* execute-path gate, via `policy.RoleAllowed`: exact allowlist membership (no `"*"`), admin always passes, empty/absent role and empty-string entries authorize nobody, and no `allowed_roles` → admin-only. Preserve and exercise via `testutil.RunRoleMatrix` / `StandardRoleMatrix` (see #159). Detail: architecture.md § `pipes/`. 14. **TypeScript SDK** — `@wavehouse/sdk`: zero-dep client, typed query builder, real-time SSE, live queries (incrementable/decomposable/poll aggregation), codegen CLI. The canonical client (see §SDK Sync). 15. **Observability invariants** — stdout always 100% (sampling is OTLP-push-only); WARN+ERROR always export at 100% (a non-configurable floor — don't expose it); gRPC OTel exporters dial lazily so an unreachable collector never blocks startup; the OTel Prometheus exporter uses a **private** `prometheus.Registry`. The OTLP endpoint/TLS/custom-CA/mTLS/headers are delegated to the OpenTelemetry SDK's standard `OTEL_EXPORTER_OTLP_*` env vars — `InitProvider` passes **no** endpoint/header options. Known gap, intentionally not patched in WaveHouse app code: the pinned gRPC logs exporter (`otlploggrpc` v0.19/v0.20) ignores the env TLS-cert vars, so a custom/private CA and mutual TLS apply to traces/metrics but **not** the logs signal (public-CA/system-roots TLS and plaintext still work for logs) — upstream bug open-telemetry/opentelemetry-go#6661. A malformed `OTEL_EXPORTER_OTLP_HEADERS` is logged and skipped by the SDK (fail-soft), not fatal. Preserve when touching the logger/sampler/provider. Detail: architecture.md § `observability/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index cbcf5c81..e860be41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Go toolchain requirement bumped to 1.26.5** (`go.mod`): the `go` directive moves from `1.26.4` to `1.26.5` so CI's `setup-go` (which reads `go.mod` via `go-version-file`) and the pre-commit `govulncheck` gate build against a standard library that clears the stdlib advisories flagged on 1.26.4. Patch-level toolchain bump only — no source changes. - **Live SSE events are now projected and serialized once per role instead of once per subscriber** (`internal/stream/hub.go` (new), `internal/stream/{subscriber,bucket,heartbeat,metrics,doc}.go`, `internal/api/stream.go`, `internal/api/hub.go` + `internal/api/transform.go` (both removed — the broadcast hub moves to `internal/stream`, and the orphaned test-only `transformForClient` is dropped), `cmd/wavehouse/main.go`, `docs/src/content/docs/architecture.md`, `AGENTS.md`, plus tests in `internal/stream/{hub,filter,subscriber,bucket,heartbeat}_test.go` and `internal/api/{stream,transform,router,errors}_test.go`): the first PR of the SSE delivery-path throughput epic ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)), building on the `internal/stream` primitives from #346. The broadcast hub moves into `internal/stream` as `Hub`: subscribers register under `(topic, role)`, and `Broadcast` decodes each event **once**, applies each subscribed role's column policy **once**, builds one SSE frame per role, and fans it to every member of that role's `Bucket`. Previously every connection independently ran `json.Unmarshal → policy.Evaluate → filterEventColumns → json.Marshal` (plus a second unmarshal just to read the `id:` timestamp) on the *same* event in its own read loop — byte-identical work repeated N times. For a single-role audience (the public dashboard, every viewer `public`) that collapses N re-projections to 1, moving the measured ~2 270 deliveries/s ceiling toward an events/s ceiling. The `(topic, role)` key is sufficient and claims-independent: column visibility derives only from the role+table policy entry, and the stream path applies no row-level filter (a documented invariant — if row-level filtering is ever added to streaming, the key must take claims into account). The handler's two `select` cases (keepalive vs. per-subscriber event) collapse into one byte-pump over a single `Subscriber.Frames()` queue carrying typed `Frame`s; the subscriber queue grows from cap 1 (keepalive-only) to 64 so live events buffer while the handler is mid-write. Gap-fill replay and `Last-Event-ID`/`?since=` resumption are unchanged (replay stays per-connection via the shared `stream.ReplayFrame`; live frames carry the same `id: `). Slow-consumer drops, silent before, now increment `wavehouse_sse_dropped_frames_total`; an inert `Subscriber.Evicted()` seam is wired for the eviction follow-up. The per-delivery OpenTelemetry span (another #294 item) was already removed in #346. **Deferred to follow-ups:** active slow-consumer eviction (#94) and right-sizing the subscriber buffer + broadcast lock cost (#152). - **CI is now a job DAG instead of one monolithic job, and the docs deploys no longer expose the Cloudflare token to PR-authored code** (`.github/workflows/ci.yml`, `.github/workflows/housekeeping.yml`, `.github/actions/setup-env/action.yml`, `Makefile`, `docs/wrangler.jsonc`, `AGENTS.md`, `docs/src/content/docs/development.md`, `docs/src/content/docs/claude-code.md`, `CONTRIBUTING.md`, `scripts/lint-pr-title.sh`, `.claude/hooks/agent-bash-gate.sh`): closes #305. The single `make ci` job becomes parallel jobs over the *same Makefile targets* (local `make ci` stays the dev mirror): `lint`, `unit`, `integration`, `e2e` (builds its own SDK dist + cover binary via `make -j test-e2e` on a warm per-suite cache and runs the suite exactly like a local run), `coverage` (a dedicated job that merges every suite's `coverage-` fragment and applies every threshold gate via `make cov` — like local `make ci`'s final step, so the gate is decoupled from the e2e suite; it's `needs: changes` only and *polls* for the fragments rather than `needs`-ing the suites, so its setup overlaps them and the merge fires ~10s after the last suite instead of serializing ~50s of setup onto the critical path), and `docs-build` (`make build-docs`, docs-affecting changes only, uploads the docs dist artifact the preview/deploy jobs consume) — public-repo runners are free and 4-core, so the pipeline spreads horizontally instead of queueing in one process. The architecture is documented once, in `.github/workflows/README.md` (DAG diagram, design invariants, cache key policy, add-a-job recipe, and the measured-but-deferred optimizations — e2e sharding among them), and the workflow's logic lives in shellcheck-gated scripts (`scripts/ci/` — `classify-changes.sh`, `check-pr-title.sh`, `docs-preview-comment.sh`, `timing-summary.sh`, `wait-artifact.sh`; over the shared, dependency-free path classifier `scripts/classify-paths.sh`, unit-tested by `scripts/classify-paths.test.sh` via `make test-classify-paths` and reused by the `pre-push` git hook so a docs/prose-only push requires only `make verify`, not a full `make ci` — the same suites CI skips for those changes) rather than inline YAML; caches are owned end-to-end by `setup-env` via nested `actions/cache` (automatic post-job saves — the per-job save-step boilerplate is gone); a non-gating `Timing summary` job writes a per-job wall-clock table to every run's Summary page; and `make verify` gains two leaves that gate the new surface area — `lint-sh` (shellcheck `v0.11.0`, checksum-verified install via `scripts/install-shellcheck.sh`) and `lint-gha` (actionlint `v1.7.12`) — so the CI plumbing is linted like any other source. The workflow also handles `merge_group` events (full suite against the merge-group ref), enabling a **merge queue** on `main`: the queue re-tests each PR against current main at landing time, which replaces the ruleset's "require branches to be up to date" rule — no more manual branch updates after every sibling merge. A new aggregator job named `CI` is the ruleset's **sole required status check** (it fails on any failed/cancelled job and counts skipped jobs as passing), so docs-only PRs skip the Go suites without orphaning the gate and future job changes never require ruleset edits. The PR-title (Conventional Commits) gate moves into the `PR title` job under that aggregator, validated by the same `scripts/lint-pr-title.sh` from a trusted `main` checkout; `PR housekeeping` (`pull_request_target`) drops to non-required and keeps what needs fork-PR write access — path labels, the sticky title-explainer comment, and a new nudge that re-runs the failed `PR title` job when a title edit fixes it (the job re-reads the title from the API, so no new push is needed). The **#305 fix**: docs previews/production deploys run in dedicated `docs-preview`/`docs-deploy` jobs that check out trusted `main` (wrangler, worker source, and config never resolve from the PR tree), consume only the static `docs/dist` artifact, and are the only jobs that reference `CLOUDFLARE_*` secrets; previews now publish right after `docs-build` instead of waiting on the full test pipeline, and the `docs-preview` deploy is **non-gating** — it's not in the `CI` aggregator's `needs` (only `docs-build` gates), so a slow or failed Cloudflare preview reports its own "Docs preview" check but never delays or reds the required check; production (`docs-deploy`, on the post-merge main push) still requires everything green. Per-job least-privilege permissions replace the old workflow-wide `contents: write`, and the Go build cache is partitioned per job (unit/integration/e2e compile with different flags) so each suite stays warm. @@ -31,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/discovery/validation_test.go`, `internal/stream/hub_test.go`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). Equality and set predicates (`_eq`/`_neq`/`_in` — the tenant/user scoping row-security actually uses) are enforced exactly — numeric ties beyond `float64` precision resolve at arbitrary precision (`math/big`), so the string-encoded 64-bit IDs ingest accepts to survive JS precision loss never falsely collide, and a `NaN` operand fails closed rather than comparing equal to everything — with one representation caveat: a `Bool`/`DateTime` filter value compares against the event's canonical text form, without ClickHouse's cross-representation coercion (`true`, not `1`); ordering predicates (`_gt`/`_lt`) are schema-informed — numeric columns compare numerically (matching ClickHouse), other types lexicographically — which is best-effort for exotic types stored as numbers (a documented boundary in access-control.mdx), with every ambiguous or uncomparable value failing closed (the row is withheld). Replay (gap-fill) applies the same per-connection row check. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant noted in the #294/#353 Changed entry below. +- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/stream/metrics.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/api.md`, `AGENTS.md`, `SECURITY.md`, `internal/stream/doc.go`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/discovery/validation_test.go`, `internal/stream/hub_test.go`, `tests/e2e/sdk/streaming.test.ts`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). The in-memory comparison is type-aware and **fails closed on anything it can't prove** (`policy.ColumnKind`, seeded from the schema registry): numeric columns (`Int*`/`UInt*`/`Float*`/`Decimal*`) compare numerically, matching ClickHouse — float64-equal ties resolve at arbitrary precision (`math/big`) and the hub decodes event payloads with `UseNumber` (exact digit strings, also keeping big integers byte-faithful on the SSE wire), so 64-bit IDs never falsely collide whether they arrive string-encoded (the JS-precision-loss escape hatch ingest accepts) or as bare JSON numbers, and an unparseable/`NaN` operand withholds the row — `String` columns compare bytewise (exactly ClickHouse's String semantics, equality *and* ordering), and every other type (`Enum`, `UUID`, `Date`/`DateTime`, `Bool`, IPs, `FixedString`, …) — plus every column when no schema is available (an unknown table, or the boot-time discovery-failure window where the server serves while retrying) — trusts byte-equality only: `_eq`/`_in` admit exactly the event's own text rendering, while `_neq`/`_gt`/`_lt` withhold the row, because a byte difference can be pure representation and admitting on it would deliver rows the query path excludes (an uppercase UUID under `_neq`; `"9" > "100"` as text under `_gt`). Ambiguity therefore always costs availability (a row withheld), never confidentiality — a guarantee about the ingested payload the stream evaluates, whose one payload-vs-stored asymmetry (insert-time numeric narrowing: `Decimal` scale, `Float32` width) is documented in the enforcement caution — and each withhold is observable via the new `wavehouse_sse_rows_withheld_total` counter (labeled by table and role, on the live and replay paths alike), separating "no matching rows" from "a fail-closed filter is withholding everything". Subscriber claims are fixed at construction (`stream.NewSubscriber(claims)` — no setter), so the fan-out's unsynchronized claims read is race-free structurally, with a dedicated `-race` test driving concurrent row-filtered broadcasts. Replay (gap-fill) applies the same per-connection row check via `Hub.ReplayProjector`, which caches the per-table column-kind lookup across the replay loop. **Perf note:** on a topic whose role carries a row-filter, part of the #294/#353 once-per-role fan-out gain is traded back for correctness — `policy.Evaluate` runs per subscriber per event there (the column projection stays shared; roles without a filter are unaffected); #435 tracks memoizing it, and `BenchmarkBroadcast_RowFilteredFanout` exists to measure it. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant noted in the #294/#353 Changed entry below. - **Policy `_in` is now enforced on both the row-`filter` and insert-`check` paths, closing a fail-open row-security gap** (`internal/policy/policy.go`, `internal/api/ingest.go`, `docs/src/content/docs/access-control.mdx`, plus tests in `internal/policy/policy_test.go`, `internal/api/ingest_test.go`): closes #224. The `Filter` schema accepted `_in` but the engine never read it: on the row-`filter`/SELECT path `resolveFilters` had no `_in` branch, so a row-security filter like `tenant_id: { _in: … }` produced **no `WHERE` predicate** and the role saw every row instead of its tenant subset (a fail-open, same family as #223); on the `check`/INSERT path only `_eq` was honored, silently dropping any other operator. `_in` now takes a single claim that resolves to a JSON **array** (the multi-tenant case — a token's `tenant_ids` list) and emits `col IN (?, …)` with one bound param per element; a scalar claim is a one-element set, and an empty/absent claim matches **no rows** (fail-closed) rather than widening to all of them. On the insert path an `_in` check requires the column be present and one of the set — there is no single value to auto-inject as `_eq` does, so an omitted column is rejected (`403 check failed`). The comparison operators are enforced on `filter` (`_eq`/`_neq`/`_gt`/`_lt`/`_in` all produce predicates now, so nothing is rejected there) and, on `check`, `_neq`/`_gt`/`_lt` become a loud config-load rejection (no insert-time semantics; `check` honors `_eq` + `_in`). The `_in` value stays a single templated string in the wire schema (Go `Filter.In`, SDK `PolicyFilter._in`), matching the established "set = array" shape of the caller-query `in` operator. - **`denied_aggregations` is now enforced case-insensitively against the caller-supplied function name, closing a policy bypass** (`internal/policy/policy.go`, plus tests in `internal/policy/policy_test.go`): closes #318. `IsAggregationAllowed` lower-cased the aggregation name only *after* the deny-list loop and the empty-allow-list early return, so the deny check compared a lower-cased deny entry against the raw caller input — a denied aggregation slipped past simply by changing case (`SUM` bypassed a `sum` deny entry, and with an empty allow list the call was then permitted). `isValidAggFn` already accepts any casing and the SQL builder emits the function name verbatim, so the denied aggregation actually executed. The case fold now happens once, before the deny check, so deny wins regardless of caller casing — matching the case-insensitive contract the access-control docs already specified. - **A role's structured-query resource caps are now enforced server-side by ClickHouse, so a public read can't outrun its budget during a server-side scan/merge/aggregation phase** (`internal/api/ch_settings.go` (new), `internal/api/structured_query.go`, `internal/policy/policy.go`, `internal/policy/scalars.go` (new), `internal/config/config.go`, `internal/query/builder.go`, `cmd/wavehouse/main.go`, `config.yaml`, `clients/ts/src/types.ts`, `docs/src/content/docs/{access-control.mdx,configuration.mdx}`, plus tests in `internal/api/ch_settings_test.go`, `internal/policy/{policy,scalars}_test.go`, `internal/config/config_test.go`, `internal/query/builder_test.go`, `tests/integration/query_limits_test.go` (all new/expanded), `tests/e2e/sdk/query.test.ts`): closes #316. The native ClickHouse connection passed **no per-query `Settings`**, so a read's policy caps bound it only client-side — a Go `context` deadline (which cancels only while the client is reading result blocks) plus a SQL `LIMIT`. Memory and rows scanned were **never bounded server-side**, so a heavy aggregation could allocate gigabytes of state or scan an entire table well within the time budget; and because `clickhouse-go` derives a `max_execution_time` setting from the context deadline only for deadlines `> 1s`, a sub-second time cap reached ClickHouse with no server-side time bound at all. The structured-query path now attaches per-query `Settings` derived from the role's resolved permissions: `max_execution_time` (fractional seconds, emitted explicitly so the sub-second case is enforced), `max_result_rows` + `result_overflow_mode=throw` (defense-in-depth behind the SQL `LIMIT`), `max_rows_to_read` + `read_overflow_mode=throw`, and `max_memory_usage` — so a query that exceeds its budget is rejected by the server (ClickHouse codes 158 / 241) rather than running to completion. **Boundary:** WaveHouse owns the *dynamic, per-role* caps (sent as per-query settings); the *global, static* backstop — which applies to every query including named pipes and raw admin SQL — is configured in **ClickHouse's own settings profiles and quotas** (documented in `configuration.mdx`), composes with the per-role caps, and holds even against a WaveHouse bug. **Schema (pre-launch):** the per-role policy fields are human-readable in / numeric out — `max_execution_time_ms` (int) → **`max_execution_time`** (set as a duration string `"5s"` or a bare ms number; read back as ms), the new **`max_memory_usage`** (set as a size string `"4GiB"` — IEC/SI respected via `dustin/go-humanize`, so `4GB` ≠ `4GiB` — or a bare byte number; read back as bytes), and the new **`max_rows_to_read`** (int), backed by two small `Millis`/`ByteSize` types in the `policy` package. The formerly hard-coded `query.DefaultMaxRows = 10000` result-LIMIT becomes the documented, tunable `query.default_max_rows` config knob (`Build` takes it as a parameter). Raw admin SQL remains unbounded by WaveHouse (governed by ClickHouse). Verified RED before the fix: the handler-level integration test confirmed a capped read returned the full result set when the settings weren't sent. diff --git a/SECURITY.md b/SECURITY.md index be700fdc..4ef0f151 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,7 +25,7 @@ We will acknowledge receipt within 48 hours and aim to provide an initial assess WaveHouse handles data and enforces strict isolation: - **JWT validation**: The JWT middleware always runs (there is no on/off switch). Signing supports either an HMAC shared secret or a remote JWKS endpoint (`auth.jwks_url`). Accepted signing algorithms are restricted to the configured verifier's family — HMAC accepts only `HS256/384/512`, JWKS only the asymmetric set (`RS*`/`ES*`/`PS*`/`EdDSA`) — and the token's `alg` header is validated before any key material is used, so `alg: none` and algorithm-confusion attacks (re-signing with `HS256` against a JWKS deployment's public key) are rejected. A request with no token, or an invalid/expired one, falls back to the policy `default_role`; elevated access requires a valid token, and a denied request that carried a bad token fails loud (`401`) rather than as a bare `403`. -- **Role-based access control**: Roles are extracted from a configurable JWT claim path. Non-admin roles have per-table, per-column, row-level policies enforced on ingest and query; the admin role (`policy.admin_role`, `"admin"` by default, exact case-sensitive match) bypasses them. The configured non-JWT operator key (`auth.operator_key`) likewise bypasses per-role policy — a matching request is authorized as a full-access platform operator without a JWT; treat it as an admin secret. A request presenting a *non-matching* operator key is logged at `WARN` and counted by `wavehouse_auth_operator_key_failures_total`, so probing of that credential is observable and alertable. +- **Role-based access control**: Roles are extracted from a configurable JWT claim path. Non-admin roles have per-table, per-column, row-level policies enforced on ingest, query, and the live SSE stream (the stream's in-memory row-filter comparison has a documented fail-closed boundary — see the access-control docs); the admin role (`policy.admin_role`, `"admin"` by default, exact case-sensitive match) bypasses them. The configured non-JWT operator key (`auth.operator_key`) likewise bypasses per-role policy — a matching request is authorized as a full-access platform operator without a JWT; treat it as an admin secret. A request presenting a *non-matching* operator key is logged at `WARN` and counted by `wavehouse_auth_operator_key_failures_total`, so probing of that credential is observable and alertable. - **Input validation**: JSON payloads are validated against ClickHouse schemas before processing. - **Query passthrough**: Raw SQL via `POST /v1/admin/query` is restricted to the admin role — the same `RequireAdmin` gate as the rest of `/v1/admin/*`. A request with no/invalid token resolves to the `default_role`, which in a production config is not the admin role (setting `default_role` equal to the admin role is a loudly-warned, dev-only escape hatch), so it cannot reach this endpoint — the one exception is a request presenting the configured `auth.operator_key`, which reaches the whole `/v1/admin/*` surface (including this endpoint) without a JWT and even under a deleted policy, so treat that key as an admin secret. Raw SQL has no per-statement scope check (a full SQL parser would be needed to authorize predicates), so the role gate is the entire authorization story. Non-admin callers use structured queries (`POST /v1/query?table={table}`, validated against schema with permission injection) or named pipes (`GET/POST /v1/pipes/{name}`); raw-SQL grants to non-admin roles via the policy engine are no longer supported (the `raw_sql` field on policies has been removed). - **Supply chain**: Third-party GitHub Actions are pinned to full commit SHAs (enforced by the repository's Actions settings — `sha_pinning_required`). `govulncheck` runs on every push/PR. Dependabot opens weekly grouped PRs for Go modules, GitHub Actions, and the npm packages — one grouped PR covering the docs site, TS SDK, and E2E tests via the root pnpm workspace. Released artifacts ship signed [Sigstore](https://www.sigstore.dev/) build-provenance attestations — verify the container image with `gh attestation verify oci://ghcr.io/wave-rf/wavehouse: --repo Wave-RF/WaveHouse`, a downloaded release-binary archive with `gh attestation verify --repo Wave-RF/WaveHouse`, and the `@wavehouse/sdk` package via its npm provenance badge or `npm audit signatures`. (Provenance covers the published binaries and image, not `go install`, which compiles from source.) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index 3804b0af..c64b0cc2 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -178,7 +178,7 @@ On a structured query (`POST /v1/query?table={table}`) the allowlist is a **hard ## Row-level security -`filter` restricts *which rows* a role can read by injecting a `WHERE` clause into the generated SQL. Each entry maps a column to a comparison whose value is usually a **JWT claim template**: +`filter` restricts *which rows* a role can read. Like the per-column decision above, one resolution drives **both** read surfaces: a structured query gets the predicates injected as a `WHERE` clause in the generated SQL, and the live stream evaluates the *same* resolved predicates in memory against each subscriber's claims before delivering an event — so row visibility can't drift between the two paths (the stream's in-memory comparison has a fail-closed boundary; see [the enforcement caution below](#where-each-rule-is-enforced)). Each entry maps a column to a comparison whose value is usually a **JWT claim template**: @@ -375,7 +375,16 @@ The same policy drives every data path, but not every field is meaningful on eve | Named pipe | `GET/POST /v1/pipes/{name}` | per-pipe `allowed_roles` (not the policy engine; see [Named Pipes](/pipes)). Resource limits come from ClickHouse's [server-wide settings](/configuration#server-side-resource-limits), not per-role policy caps | :::caution[Live streams enforce column and row policy, but not resource limits] -SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Equality and set predicates (`_eq`, `_neq`, `_in`) — the usual tenant/user scoping — are enforced exactly: numeric ties resolve at full precision (an ID beyond `float64`'s 2^53 never falsely matches a neighbor) and an unparseable or `NaN` operand withholds the row. One representation caveat: a `Bool` (or `DateTime`) value compares by its canonical text form, without ClickHouse's cross-representation coercion — write the filter value the way your events carry it (`true`, not `1`). Ordering predicates (`_gt`, `_lt`) are best-effort: the stream compares numeric columns numerically (matching ClickHouse) but lacks ClickHouse's full per-type coercion, so an ordering filter on an exotic type (a `Decimal`/`Int128` beyond `float64` precision, or a `DateTime` ingested as a Unix number) can admit or withhold a row the query path wouldn't. The resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) remain a property of the SQL query path and are **not** applied to the live event stream; if those caps are part of a role's isolation story, don't rely on them over the stream. +SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. The stream evaluates predicates in memory, without ClickHouse's per-type coercion, so how much it can enforce depends on what the schema says about the filtered column — and every case it cannot decide **fails closed**: ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide. + +- **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a string-encoded 64-bit ID never falsely matches a neighbor); an unparseable or `NaN` operand withholds the row. +- **`String` columns** (again under any `Nullable`/`LowCardinality` wrapping): byte comparison *is* ClickHouse's String comparison — equality and ordering are both exact. +- **Every other type** (`Enum`, `UUID`, `Date`/`DateTime`, `Bool`, `IPv4`/`IPv6`, `FixedString`, …): only byte-equality is trusted. `_eq`/`_in` admit exactly the event's own text rendering — write the filter value the way your events carry it (`true`, not `1`; a lowercase UUID if that's what clients send). `_neq`, `_gt` and `_lt` withhold **every** row: a text difference can be pure representation (an uppercase UUID, an alternate date format, an Enum name vs. its number), so inequality and order are unprovable without ClickHouse — on these columns, use the query path for ordering/exclusion filters. +- **No usable schema** — the table is unknown to schema discovery, or discovery is still failing at boot (the server serves while retrying in the background): every column is treated as the "other" bucket above. Equality scoping keeps working; ordering and `_neq` withhold until a schema is available. + +Two more fail-closed edges worth knowing when you write a policy. The stream evaluates the **ingested event payload**, not the stored row — so a filter keyed on a column the payload doesn't carry withholds *every* event for that subscriber, even though the same filter matches normally on the query path. That bites a `MATERIALIZED`/`ALIAS` column (never part of an ingest payload) or a `DEFAULT` column your clients omit. The recommended [`check` + `filter` pairing](#insert-checks) is unaffected: an `_eq` insert `check` auto-injects its claim value into any payload that omits the column *before* the event is published, so the streamed event carries it and the matching row filter evaluates normally. And any non-scalar event value (array/object/null) under a filtered column withholds the row. The payload-vs-stored distinction also bounds the fail-closed guarantee itself: it holds for the *payload* value, and the one place that differs from the stored row is insert-time numeric narrowing — a payload with more precision than the column's declared type (a `Decimal`'s scale, `Float32` width) is rounded on insert, so a numeric *threshold* filter can deliver an event whose stored row rounds to the other side of the boundary. Equality scoping on identity columns (integer IDs, `String` tenants) is not affected — those store exactly. Rows withheld by row-level security are counted in `wavehouse_sse_rows_withheld_total` (labeled by table and role) — check it before concluding a quiet stream simply has no matching rows. + +The resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) remain a property of the SQL query path and are **not** applied to the live event stream; if those caps are part of a role's isolation story, don't rely on them over the stream. ::: ## Managing the policy diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index 3302b5af..f85bac5d 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -539,7 +539,7 @@ data: {"table_name":"clicks","received_timestamp":"2026-03-24T12:00:01.456Z","da Each SSE connection is bound to a single `?table=`; to consume multiple tables, open one connection per table. -**Note:** When access control policies are active, streamed events are filtered per the caller's role — denied columns are removed and tables without select permission are skipped. +**Note:** When access control policies are active, streamed events are filtered per the caller's role — tables without select permission are skipped, denied columns are removed from each event, and the role's row-level `filter` is evaluated per subscriber against the caller's JWT claims, so each connection receives only the rows it could also read over `POST /v1/query`. The claims come from the connection's token (the `Authorization` header, or the `?token=` fallback above), and replayed gap-fill events are filtered the same way. See [Access control — row-level security](/access-control#row-level-security) and the enforcement caution there for the stream's fail-closed comparison boundary. **CORS:** `/v1/stream` honors the `server.cors_allowed_origins` allowlist like every endpoint, so a browser `EventSource` from an allowed origin connects normally. `Last-Event-ID` is allow-listed in the CORS preflight so fetch-based clients can resume cross-origin. diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index f35d5e80..4601f1cf 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -85,11 +85,11 @@ The API layer uses [Chi](https://github.com/go-chi/chi) for routing with Request The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)) lives next to the keepalive primitives it shares. One abstraction per file. -- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the numeric-aware comparison seeded from the schema registry). This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayFrame` shares the same projection and per-connection row check for the handler's gap-fill. +- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the type-aware comparison seeded from the schema registry — `policy.ColumnKind`: numeric columns compare numerically, `String` bytewise, everything else admits byte-equality only and fails ordering/`!=` closed, so a missing schema can never downgrade the comparison to a leak). Each row withheld this way increments `wavehouse_sse_rows_withheld_total`. This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayProjector` shares the same projection and per-connection row check for the handler's gap-fill, caching the per-table column-kind lookup across the replay loop. - **subscriber.go** — `Subscriber`, the per-connection handle. It owns a single ready-to-write outbound queue of `Frame`s (each tagged with its `kind`, so the handler labels the write where it happens): producers — the keepalive wheel and the event `Hub` — fan frames in with `Send` (non-blocking; a full queue drops and the `Hub` counts it), and the handler drains `Frames()` to the client verbatim. The queue is sized for buffering live events (cap 64, up from the keepalive-only cap 1; #152 will make it a knob), and an `Evicted()` channel is the seam the slow-consumer follow-up closes to disconnect a wedged consumer. - **bucket.go** — `Bucket`, the reusable fan-out primitive: a concurrency-safe set of subscribers. `Push` delivers a shared `Frame` to each fire-and-forget (the keepalive wheel's ring); `Snapshot` exposes the members so the event `Hub` can fan out while inspecting each `Send` result (to count drops). The `Hub` holds one `Bucket` per `(topic, role)` so a projected frame is built once and sent to every member instead of re-projected per subscriber. - **heartbeat.go** — The keepalive wheel (`Heartbeater`). A single process-wide ticker fans a minimal `:` comment across the ring of `Bucket`s, waking ~1/N of live streams per tick so the writes don't synchronize. The effective per-connection keepalive period is `stream.keepalive_interval` (the wheel ticks every `keepalive_interval ÷ keepalive_buckets`, so one rotation spans the interval); the owning handler goroutine does the actual write, so the shared ticker never touches a `ResponseWriter` directly. -- **metrics.go** — `Metrics`, the SSE instrument set: `wavehouse_sse_active_streams` (open streams), `wavehouse_sse_stream_duration_seconds` (lifetime), `wavehouse_sse_frames_sent_total` / `wavehouse_sse_bytes_sent_total` (labeled by `kind`: `keepalive`, `event`, `replay`), and `wavehouse_sse_dropped_frames_total` (frames dropped to a full subscriber queue — the slow-consumer signal that was silent before #294). Nil-safe, so the handler holds one unconditionally and tests skip wiring it; one shared instance records both the handler's write sites and the `Hub`'s drop counts. Separate from `observability.RegisterSystemMetrics`, which covers only the NATS/Pebble system gauges. Streams are observed through these metrics rather than per-event traces (the router excludes `/v1/stream` from the HTTP tracer). +- **metrics.go** — `Metrics`, the SSE instrument set: `wavehouse_sse_active_streams` (open streams), `wavehouse_sse_stream_duration_seconds` (lifetime), `wavehouse_sse_frames_sent_total` / `wavehouse_sse_bytes_sent_total` (labeled by `kind`: `keepalive`, `event`, `replay`), `wavehouse_sse_dropped_frames_total` (frames dropped to a full subscriber queue — the slow-consumer signal that was silent before #294), and `wavehouse_sse_rows_withheld_total` (rows withheld from a subscriber by the row-level-security filter, labeled by table and role — the signal that separates "no matching rows" from "a fail-closed filter is withholding everything"). Nil-safe, so the handler holds one unconditionally and tests skip wiring it; one shared instance records both the handler's write sites and the `Hub`'s drop counts. Separate from `observability.RegisterSystemMetrics`, which covers only the NATS/Pebble system gauges. Streams are observed through these metrics rather than per-event traces (the router excludes `/v1/stream` from the HTTP tracer). ### `auth/` — Authentication @@ -114,7 +114,7 @@ The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https:/ ### `discovery/` — Schema Discovery & Validation - **discovery.go** — `SchemaRegistry` queries `system.columns` to discover ClickHouse table schemas. Supports periodic auto-refresh, on-demand refresh, and `RetryRefresh` (boot-time exponential backoff loop used by `cmd/wavehouse` so a transiently unreachable ClickHouse doesn't crash-loop the binary). Thread-safe via `sync.RWMutex`. -- **validation.go** — `Validate(schema, data)` checks incoming JSON against the discovered schema: unknown fields, type compatibility, missing required columns, null handling. +- **validation.go** — `Validate(schema, data)` checks incoming JSON against the discovered schema: unknown fields, type compatibility, missing required columns, null handling. Also exports the type classifiers `IsNumericType` / `IsStringType` (unwrapping `Nullable`/`LowCardinality`), which seed the stream row-filter's `policy.ColumnKind` comparison. - **discovery_test.go** — Unit tests for validation logic. ### `ingest/` — Ingest Pipeline, DLQ & Sweeping @@ -139,7 +139,8 @@ The package's design invariants — stdout always 100%, WARN+ERROR always export ### `policy/` — Access Control -- **policy.go** — Hasura-style policy types (`Policy`, `TablePolicy`, `RolePermissions`, `Filter`), `Evaluate()` function that resolves permissions against JWT claims (including `{{ jwt.claim.path }}` template resolution), the per-column decision `IsColumnAllowed()` plus its batch/projection forms `AllowedProjection()` and `RestrictsColumns()` (used to expand a `select_all` request into a role's allowed columns), `IsAggregationAllowed()`, `Validate()`. +- **policy.go** — Hasura-style policy types (`Policy`, `TablePolicy`, `RolePermissions`, `Filter`), `Evaluate()` function that resolves permissions against JWT claims (including `{{ jwt.claim.path }}` template resolution), the per-column decision `IsColumnAllowed()` plus its batch/projection forms `AllowedProjection()` and `RestrictsColumns()` (used to expand a `select_all` request into a role's allowed columns), `IsAggregationAllowed()`, `Validate()`. `Evaluate` resolves a role's row-`filter` **once** (`resolvePredicates`) and feeds both read surfaces from that single resolution — `predicatesToSQL` renders the query path's `WHERE`, and the same predicates ride on `ResolvedPermissions` for the stream's in-memory check — so row visibility can't drift between them (#319). +- **rowfilter.go** — the in-memory row-visibility twin of the SQL `WHERE`: `HasRowFilter`, `RowVisible` (evaluates the resolved predicates against a decoded event, per subscriber), and `ColumnKind` (`Numeric`/`Text`/`Opaque`) — the type-aware comparison classification whose zero value is the fail-closed floor: numeric columns compare numerically with full-precision tie-breaks, `String` bytewise, and everything else (including any column with no usable schema) admits byte-equality only, failing `!=`/`>`/`<` closed. - **store.go** — `Store` backed by NATS KV bucket `WAVEHOUSE_POLICY`. Supports file-based bootstrap (YAML/JSON), cluster-wide sync via KV Watch, local caching. ### `pipes/` — Named Query Pipes @@ -256,11 +257,16 @@ Client GET /v1/stream → Create ephemeral NATS consumer with DeliverByStartTime → Send historical events (projected per-connection) first → Live events: MQ → Hub.Broadcast → projected & serialized ONCE per role - → fan the finished frame to every Subscriber of that (topic, role) + → fan the finished frame to every Subscriber of that (topic, role); + a role carrying a row-level filter delivers per subscriber instead: + the shared frame goes only to subscribers whose JWT claims admit + the row (RowVisible) → Handler drains keepalives + event frames from one byte-pump → client - → Per-role policy filtering (historical + live): denied tables skipped, - denied columns stripped. Live projection runs once per role (Hub.Broadcast); - replay shares the same column policy but projects per-connection + → Policy filtering (historical + live): denied tables skipped, denied + columns stripped, row filter evaluated per subscriber against claims. + Column projection runs once per role (Hub.Broadcast) — per-subscriber + work only where a row filter makes visibility per-connection; replay + shares the same column policy + row check but projects per-connection ``` ## Technology Stack diff --git a/internal/api/stream.go b/internal/api/stream.go index a0727573..caf6acc8 100644 --- a/internal/api/stream.go +++ b/internal/api/stream.go @@ -81,8 +81,7 @@ func (h *StreamHandler) Handle(w http.ResponseWriter, r *http.Request) { // Register for live events before gap-fill so events arriving during replay // buffer in the subscriber queue instead of being missed (an overlap with // replay yields duplicates, deduped client-side by id: — at-least-once). - sub := stream.NewSubscriber() - sub.SetClaims(claims) + sub := stream.NewSubscriber(claims) h.Hub.Add(topic, role, sub) defer h.Hub.Remove(topic, role, sub) @@ -99,8 +98,9 @@ func (h *StreamHandler) Handle(w http.ResponseWriter, r *http.Request) { // low-volume and one-time, unlike the per-role live fan-out), write, and count // the replayed frame. A write error means the client is gone, so stop the // gap-fill and let the deferred cleanup unwind. + project := h.Hub.ReplayProjector(role, claims) sendReplay := func(data []byte) bool { - f, ok := h.Hub.ReplayFrame(role, claims, data) + f, ok := project(data) if !ok { return true // filtered for this role — skip } diff --git a/internal/discovery/validation.go b/internal/discovery/validation.go index b2dbdf51..67ef15a6 100644 --- a/internal/discovery/validation.go +++ b/internal/discovery/validation.go @@ -151,12 +151,22 @@ func isTypeCompatible(chType string, val any) bool { // IsNumericType reports whether chType is a ClickHouse numeric type (integer, float, // or decimal), unwrapping Nullable/LowCardinality modifiers first. The stream -// row-filter evaluator uses it to choose numeric vs lexicographic comparison for a -// column, so ordering predicates (>, <) on numbers match ClickHouse (9 < 100). +// row-filter evaluator classifies such columns numeric-comparable, so ordering +// predicates (>, <) on numbers match ClickHouse (9 < 100). func IsNumericType(chType string) bool { return isNumericType(unwrapType(chType)) } +// IsStringType reports whether chType is a ClickHouse String (unwrapping +// Nullable/LowCardinality). For String columns, byte comparison IS ClickHouse +// comparison — equality and lexicographic order alike — so the stream row-filter +// evaluator can compare them exactly. FixedString is deliberately excluded: its +// stored values are zero-padded to the declared width, so a byte comparison of an +// ingested value against a filter constant would not match ClickHouse. +func IsStringType(chType string) bool { + return unwrapType(chType) == "String" +} + // isNumericType returns true for ClickHouse integer, float, and decimal types. func isNumericType(chType string) bool { switch { diff --git a/internal/discovery/validation_test.go b/internal/discovery/validation_test.go index 4a4015d1..4ef2268d 100644 --- a/internal/discovery/validation_test.go +++ b/internal/discovery/validation_test.go @@ -253,3 +253,34 @@ func TestIsNumericType_Exported(t *testing.T) { }) } } + +// TestIsStringType: only String (under any Nullable/LowCardinality wrapping) +// qualifies — byte comparison is ClickHouse comparison for it. FixedString is +// excluded on purpose (zero-padded storage), as is everything whose text form is +// not canonical (UUID, Enum, DateTime, Bool). +func TestIsStringType(t *testing.T) { + t.Parallel() + + tests := []struct { + chType string + want bool + }{ + {"String", true}, + {"Nullable(String)", true}, + {"LowCardinality(String)", true}, + {"LowCardinality(Nullable(String))", true}, + {"FixedString(16)", false}, + {"UUID", false}, + {"Enum8('a' = 1)", false}, + {"DateTime", false}, + {"Bool", false}, + {"UInt64", false}, + } + + for _, tt := range tests { + t.Run(tt.chType, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, IsStringType(tt.chType)) + }) + } +} diff --git a/internal/policy/rowfilter.go b/internal/policy/rowfilter.go index b008165b..f570e747 100644 --- a/internal/policy/rowfilter.go +++ b/internal/policy/rowfilter.go @@ -1,6 +1,7 @@ package policy import ( + "encoding/json" "math" "math/big" "strconv" @@ -16,24 +17,55 @@ func (p *ResolvedPermissions) HasRowFilter() bool { return p != nil && len(p.rowFilter) > 0 } +// ColumnKind classifies a column's ClickHouse type for the in-memory row-filter +// comparison. The zero value is ColumnOpaque, so a nil map, a column absent from +// the map, and a column the schema doesn't know all land on the most conservative +// class — the three "no type knowledge" states are indistinguishable and equally +// closed, never a silent downgrade to a laxer comparison. +type ColumnKind uint8 + +const ( + // ColumnOpaque: no usable type knowledge (no schema, unknown column) or a type + // whose text rendering is not canonical — UUID (case), Enum (name vs number), + // Bool (true vs 1), Date/DateTime (formats), IPv4/IPv6, … For these only + // byte-equality is trustworthy: identical strings parse to identical ClickHouse + // values, but differing strings prove nothing. So = and in admit exactly the + // event's own rendering, while !=, > and < fail closed (the row is withheld). + ColumnOpaque ColumnKind = iota + // ColumnNumeric (Int*/UInt*/Float*/Decimal*): operands parse as numbers and + // compare numerically, with float64-equal ties resolved at full precision. + ColumnNumeric + // ColumnText (String, incl. Nullable/LowCardinality): byte comparison is + // ClickHouse comparison — equality AND lexicographic order — so every operator + // is exact. FixedString is NOT ColumnText (zero-padded storage). + ColumnText +) + // RowVisible reports whether row satisfies every resolved row-filter predicate — the // in-memory twin of the query path's WHERE clause, evaluated against a decoded event // so the stream applies the same row-level security the query path does. Predicates // are ANDed; the query path joins them with AND too. // -// numericCols maps column name → true when that column's ClickHouse type is numeric -// (the caller supplies it from the table schema; see discovery.IsNumericType). -// Numeric columns compare numerically, so 9 < 100 as ClickHouse would; every other -// column — and any column absent from the map, e.g. when no schema is available — -// compares as text. This is exact for the equality/set operators row-level security -// actually uses (=, !=, in) and best-effort for ordering (>, <): it cannot perfectly -// mirror ClickHouse's per-type coercion for exotic types (Decimal / Int128 beyond -// float64 precision, Date/DateTime stored as Unix numbers). Every ambiguous or -// uncomparable case fails closed — the row is hidden, never leaked — so the boundary -// costs availability, not confidentiality. +// colKinds maps column name → ColumnKind, supplied by the caller from the table +// schema (see stream.Hub's columnKinds). Numeric columns compare numerically (9 < +// 100, as ClickHouse would), String columns compare bytewise (exactly ClickHouse's +// String collation), and everything else — including every column when no schema is +// available — admits only byte-equality (= / in) and fails !=, > and < closed: the +// evaluator cannot mirror ClickHouse's per-type coercion, and text comparison there +// could admit rows the query path excludes ("9" > "100" as text, an uppercase UUID +// under !=). Every ambiguous or uncomparable case fails closed — the row is hidden, +// never leaked — so the boundary costs availability, not confidentiality. +// +// That guarantee is about the INGESTED PAYLOAD value, which is what the stream +// evaluates; the query path evaluates the stored row. The one residual asymmetry is +// insert-time numeric narrowing — a payload carrying more precision than the +// column's declared type (a Decimal's scale, Float32 width) is rounded on insert, +// so a numeric threshold filter can admit an event whose stored row lands on the +// other side of the boundary. Documented in access-control.mdx's enforcement +// caution. // // A nil receiver (no policy applies) makes every row visible. -func (p *ResolvedPermissions) RowVisible(row map[string]any, numericCols map[string]bool) bool { +func (p *ResolvedPermissions) RowVisible(row map[string]any, colKinds map[string]ColumnKind) bool { if p == nil { return true } @@ -43,7 +75,7 @@ func (p *ResolvedPermissions) RowVisible(row map[string]any, numericCols map[str return false } for _, pred := range p.rowFilter { - if !pred.matches(row, numericCols[pred.Column]) { + if !pred.matches(row, colKinds[pred.Column]) { return false } } @@ -52,27 +84,27 @@ func (p *ResolvedPermissions) RowVisible(row map[string]any, numericCols map[str // matches evaluates one predicate against the row, failing closed (false) whenever // the value is absent or can't be compared as required. -func (pred resolvedPredicate) matches(row map[string]any, numeric bool) bool { +func (pred resolvedPredicate) matches(row map[string]any, kind ColumnKind) bool { raw, ok := row[pred.Column] if !ok { return false // column not in the event ⇒ can't prove the row is allowed } switch pred.Op { case "=": - c, ok := compareScalar(raw, pred.Values[0], numeric) + c, ok := compareScalar(raw, pred.Values[0], kind) return ok && c == 0 case "!=": - c, ok := compareScalar(raw, pred.Values[0], numeric) + c, ok := compareScalar(raw, pred.Values[0], kind) return ok && c != 0 case ">": - c, ok := compareScalar(raw, pred.Values[0], numeric) + c, ok := compareScalar(raw, pred.Values[0], kind) return ok && c > 0 case "<": - c, ok := compareScalar(raw, pred.Values[0], numeric) + c, ok := compareScalar(raw, pred.Values[0], kind) return ok && c < 0 case "in": for _, v := range pred.Values { - if c, ok := compareScalar(raw, v, numeric); ok && c == 0 { + if c, ok := compareScalar(raw, v, kind); ok && c == 0 { return true } } @@ -83,15 +115,19 @@ func (pred resolvedPredicate) matches(row map[string]any, numeric bool) bool { } // compareScalar compares an event value against a resolved filter value, returning -// -1/0/+1 and ok=false when the comparison can't be made (a non-scalar event value, -// or a numeric comparison whose operands don't parse as numbers). numeric selects -// numeric vs lexicographic ordering. -func compareScalar(rowVal any, filterVal string, numeric bool) (int, bool) { +// -1/0/+1 and ok=false when the comparison can't be made: a non-scalar event value, +// a numeric comparison whose operands don't parse as numbers, or two unequal values +// of a ColumnOpaque column (where inequality and order are unprovable). Every +// ok=false fails the enclosing predicate closed — for != that is what keeps a mere +// representation difference (an uppercase UUID, 1 for a Bool true) from being +// mistaken for a real inequality and admitting a row the query path excludes. +func compareScalar(rowVal any, filterVal string, kind ColumnKind) (int, bool) { s, ok := scalarString(rowVal) if !ok { return 0, false } - if numeric { + switch kind { + case ColumnNumeric: a, err1 := strconv.ParseFloat(s, 64) b, err2 := strconv.ParseFloat(filterVal, 64) // NaN must be rejected explicitly: ParseFloat accepts "NaN", and NaN's @@ -106,13 +142,27 @@ func compareScalar(rowVal any, filterVal string, numeric bool) (int, bool) { return 1, true default: // Float equality is not proof of equality: distinct integers beyond - // 2^53 (string-encoded on ingest exactly to survive JS precision loss) - // collapse to one float64, and rounding is monotonic so only the - // equal case is in doubt. Resolve the tie at full precision. + // 2^53 collapse to one float64 — whether they arrived string-encoded + // (ingest accepts that exactly to survive JS precision loss) or as bare + // JSON numbers (the stream decodes with UseNumber so s still carries + // the exact digits). Rounding is monotonic so only the equal case is + // in doubt. Resolve the tie at full precision. return compareExact(s, filterVal) } + case ColumnText: + return strings.Compare(s, filterVal), true + case ColumnOpaque: + // Byte-equality is the only relation provable without type knowledge + // (identical strings always parse to the same ClickHouse value); unequal + // bytes prove nothing, so the comparison is refused and the predicate + // fails closed. + if s == filterVal { + return 0, true + } + return 0, false + default: + return 0, false // unknown future kind: refuse to compare, fail closed } - return strings.Compare(s, filterVal), true } // compareExact compares two numeric strings at arbitrary precision — the tie-break @@ -132,13 +182,19 @@ func compareExact(a, b string) (int, bool) { // scalarString renders a JSON-decoded scalar as the canonical string compared // against a (string-valued) filter. Non-scalars (arrays, objects, null) return -// ok=false so the predicate fails closed rather than guessing. JSON numbers arrive -// as float64 via encoding/json; -1 precision emits the shortest round-trip form -// without an exponent, so integer IDs read back as "123", not "1.23e+02". +// ok=false so the predicate fails closed rather than guessing. The stream decodes +// events with UseNumber, so JSON numbers arrive as json.Number — the exact digit +// string, which is what lets compareExact distinguish 64-bit IDs that would +// collapse into one float64. The float64 case remains for callers that decoded +// without UseNumber (tests, future paths); -1 precision emits the shortest +// round-trip form without an exponent, so integer IDs read back as "123", not +// "1.23e+02". func scalarString(v any) (string, bool) { switch x := v.(type) { case string: return x, true + case json.Number: + return string(x), true case float64: return strconv.FormatFloat(x, 'f', -1, 64), true case bool: diff --git a/internal/policy/rowfilter_test.go b/internal/policy/rowfilter_test.go index 6e6103cc..45ee3505 100644 --- a/internal/policy/rowfilter_test.go +++ b/internal/policy/rowfilter_test.go @@ -1,6 +1,7 @@ package policy import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -43,26 +44,62 @@ func TestRowVisible_InSet(t *testing.T) { assert.False(t, empty.RowVisible(map[string]any{"tenant_id": "a"}, nil), "absent claim ⇒ empty set ⇒ no row matches") } +// TestRowVisible_Neq: != admits a row only when inequality is PROVABLE — a numeric +// column comparing numerically or a String column comparing bytewise. On a column +// with no usable type (no schema, or a type like UUID/Bool whose text rendering +// isn't canonical) a byte difference may be pure representation — 'ABC-def' vs +// 'abc-def' for a UUID ClickHouse would treat as equal — so != fails closed rather +// than admit a row the query path excludes. func TestRowVisible_Neq(t *testing.T) { t.Parallel() + text := map[string]ColumnKind{"status": ColumnText} perms := evalRowFilter(t, map[string]Filter{"status": {Neq: new("deleted")}}, nil) - assert.True(t, perms.RowVisible(map[string]any{"status": "active"}, nil)) - assert.False(t, perms.RowVisible(map[string]any{"status": "deleted"}, nil)) + assert.True(t, perms.RowVisible(map[string]any{"status": "active"}, text), "String column: byte inequality is real inequality") + assert.False(t, perms.RowVisible(map[string]any{"status": "deleted"}, text)) + + assert.False(t, perms.RowVisible(map[string]any{"status": "active"}, nil), + "no schema: byte inequality proves nothing, fail closed") + + uuid := evalRowFilter(t, map[string]Filter{"device": {Neq: new("ABC-DEF")}}, nil) + assert.False(t, uuid.RowVisible(map[string]any{"device": "abc-def"}, map[string]ColumnKind{"device": ColumnOpaque}), + "opaque column (e.g. UUID): a case difference is not proof of inequality — fail closed") + + num := evalRowFilter(t, map[string]Filter{"amount": {Neq: new("100")}}, nil) + kinds := map[string]ColumnKind{"amount": ColumnNumeric} + assert.True(t, num.RowVisible(map[string]any{"amount": float64(250)}, kinds), "numeric column: 250 ≠ 100 is provable") + assert.False(t, num.RowVisible(map[string]any{"amount": float64(100)}, kinds)) } -// TestRowVisible_Ordering_NumericVsLexicographic is the schema-informed behavior: an -// event value of 9 is numerically LESS than 100 but lexicographically GREATER -// ("9" > "100"). With the column marked numeric the row is hidden (matching -// ClickHouse); the no-schema lexicographic fallback would leak it — the footgun the -// schema closes. -func TestRowVisible_Ordering_NumericVsLexicographic(t *testing.T) { +// TestRowVisible_Ordering_SchemaInformed: ordering needs type knowledge. An event +// value of 9 is numerically LESS than 100 but lexicographically GREATER ("9" > +// "100") — so a numeric column compares numerically (matching ClickHouse), a String +// column compares bytewise (which IS ClickHouse's String order), and a column with +// no usable type fails closed: no schema, a column the schema doesn't know, and a +// non-numeric non-String type (Enum order follows enum values, not names; Date +// formats vary) must all withhold rather than fall back to a text comparison that +// can admit rows the query path excludes. +func TestRowVisible_Ordering_SchemaInformed(t *testing.T) { t.Parallel() perms := evalRowFilter(t, map[string]Filter{"amount": {Gt: new("100")}}, nil) - row := map[string]any{"amount": float64(9)} - - assert.False(t, perms.RowVisible(row, map[string]bool{"amount": true}), "numeric: 9 is not > 100") - assert.True(t, perms.RowVisible(row, nil), `lexicographic fallback: "9" > "100"`) - assert.True(t, perms.RowVisible(map[string]any{"amount": float64(250)}, map[string]bool{"amount": true})) + small := map[string]any{"amount": float64(9)} + big := map[string]any{"amount": float64(250)} + numeric := map[string]ColumnKind{"amount": ColumnNumeric} + + assert.False(t, perms.RowVisible(small, numeric), "numeric: 9 is not > 100") + assert.True(t, perms.RowVisible(big, numeric)) + + assert.False(t, perms.RowVisible(small, nil), `no schema: fail closed — never the "9" > "100" text leak`) + assert.False(t, perms.RowVisible(big, nil), "no schema: fail closed even when the numbers would pass") + assert.False(t, perms.RowVisible(small, map[string]ColumnKind{"other": ColumnNumeric}), + "column absent from a known schema: fail closed") + assert.False(t, perms.RowVisible(small, map[string]ColumnKind{"amount": ColumnOpaque}), + "opaque type (Enum/Date/UUID/…): order is unprovable, fail closed") + + // String columns order bytewise in ClickHouse, so ordering there is exact. + page := evalRowFilter(t, map[string]Filter{"page": {Gt: new("/m")}}, nil) + text := map[string]ColumnKind{"page": ColumnText} + assert.True(t, page.RowVisible(map[string]any{"page": "/z"}, text)) + assert.False(t, page.RowVisible(map[string]any{"page": "/a"}, text)) } // TestRowVisible_NumericEquality_FloatFormatting: a JSON float64(100) equals the @@ -71,7 +108,7 @@ func TestRowVisible_Ordering_NumericVsLexicographic(t *testing.T) { func TestRowVisible_NumericEquality_FloatFormatting(t *testing.T) { t.Parallel() perms := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("100")}}, nil) - num := map[string]bool{"amount": true} + num := map[string]ColumnKind{"amount": ColumnNumeric} assert.True(t, perms.RowVisible(map[string]any{"amount": float64(100)}, num)) assert.False(t, perms.RowVisible(map[string]any{"amount": float64(101)}, num)) } @@ -82,7 +119,7 @@ func TestRowVisible_NumericEquality_FloatFormatting(t *testing.T) { // NaN must withhold the row instead. func TestRowVisible_NaN_FailsClosed(t *testing.T) { t.Parallel() - num := map[string]bool{"amount": true} + num := map[string]ColumnKind{"amount": ColumnNumeric} byRow := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("100")}}, nil) assert.False(t, byRow.RowVisible(map[string]any{"amount": "NaN"}, num), "NaN row value must not equal 100") @@ -99,13 +136,20 @@ func TestRowVisible_NaN_FailsClosed(t *testing.T) { // collapse distinct IDs that round to the same float64 (adjacent values past 2^53). func TestRowVisible_NumericEquality_ExactBeyondFloat64(t *testing.T) { t.Parallel() - num := map[string]bool{"id": true} + num := map[string]ColumnKind{"id": ColumnNumeric} perms := evalRowFilter(t, map[string]Filter{"id": {Eq: new("9007199254740993")}}, nil) assert.False(t, perms.RowVisible(map[string]any{"id": "9007199254740992"}, num), "float64-equal neighbors are not equal") assert.True(t, perms.RowVisible(map[string]any{"id": "9007199254740993"}, num)) assert.True(t, perms.RowVisible(map[string]any{"id": "9007199254740993.0"}, num), "same value in a different rendering still matches") + // Bare JSON numbers reach RowVisible as json.Number (the stream decodes with + // UseNumber), so the same exactness holds without string-encoding: a lossy + // float64 decode would have collapsed these neighbors and delivered another + // tenant's row. + assert.False(t, perms.RowVisible(map[string]any{"id": json.Number("9007199254740992")}, num), "json.Number neighbor is not equal") + assert.True(t, perms.RowVisible(map[string]any{"id": json.Number("9007199254740993")}, num)) + neq := evalRowFilter(t, map[string]Filter{"id": {Neq: new("9007199254740993")}}, nil) assert.True(t, neq.RowVisible(map[string]any{"id": "9007199254740992"}, num), "the exact tie-break keeps distinct IDs unequal for !=") } @@ -134,7 +178,7 @@ func TestRowVisible_MultiplePredicates_AllMustPass(t *testing.T) { "tenant_id": {Eq: new("{{ jwt.tenant }}")}, "amount": {Gt: new("100")}, }, map[string]any{"tenant": "acme"}) - num := map[string]bool{"amount": true} + num := map[string]ColumnKind{"amount": ColumnNumeric} assert.True(t, perms.RowVisible(map[string]any{"tenant_id": "acme", "amount": float64(250)}, num)) assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "acme", "amount": float64(9)}, num), "amount fails") assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "globex", "amount": float64(250)}, num), "tenant fails") diff --git a/internal/stream/bucket_test.go b/internal/stream/bucket_test.go index 75a449b2..ee78bbfe 100644 --- a/internal/stream/bucket_test.go +++ b/internal/stream/bucket_test.go @@ -12,8 +12,8 @@ func TestSubscriberSet_AddRemoveLen(t *testing.T) { s := newSubscriberSet() assert.Equal(t, 0, s.Len()) - s1 := NewSubscriber() - s2 := NewSubscriber() + s1 := NewSubscriber(nil) + s2 := NewSubscriber(nil) s.Add(s1) s.Add(s2) assert.Equal(t, 2, s.Len()) @@ -50,7 +50,7 @@ func TestSubscriberSet_PushIsNonBlockingAndDropsWhenFull(t *testing.T) { func TestSubscriberSet_PushAfterRemove_NoPanicNoBlock(t *testing.T) { t.Parallel() s := newSubscriberSet() - sub := NewSubscriber() + sub := NewSubscriber(nil) s.Add(sub) s.Remove(sub) frame := Frame{Kind: KindKeepalive, Data: []byte(":\n\n")} diff --git a/internal/stream/doc.go b/internal/stream/doc.go index 077970c5..3d3225a6 100644 --- a/internal/stream/doc.go +++ b/internal/stream/doc.go @@ -8,5 +8,8 @@ // serialized ONCE per role, then pushed through the same Subscriber queue the // keepalive wheel uses, so the handler drains both from a single byte-pump. That // collapses the prior per-subscriber unmarshal/project/re-serialize into one pass -// per distinct (role, table) output shape. +// per distinct (role, table) output shape. The one per-subscriber decision left is +// row-level security (#319): a role whose policy carries a row-filter shares the +// column projection but delivers each event only to the subscribers whose JWT +// claims admit the row. package stream diff --git a/internal/stream/heartbeat_test.go b/internal/stream/heartbeat_test.go index 13812621..b8a5b5ab 100644 --- a/internal/stream/heartbeat_test.go +++ b/internal/stream/heartbeat_test.go @@ -42,7 +42,7 @@ func TestHeartbeater_AddPlacesSubscriberInLastToFireBucket(t *testing.T) { // A long period means the ticker never fires during the test, so bucket // membership is deterministic. hb := NewHeartbeater(time.Hour, 3) - sub := NewSubscriber() + sub := NewSubscriber(nil) hb.Add(sub) // hand starts at 0; buckets[0] fires next, so a new subscriber lands in @@ -101,7 +101,7 @@ func TestHeartbeater_PushesKeepaliveToIdleSubscriber(t *testing.T) { hb := NewHeartbeater(5*time.Millisecond, 1) go hb.Run(t.Context()) - sub := NewSubscriber() + sub := NewSubscriber(nil) hb.Add(sub) defer hb.Remove(sub) @@ -140,13 +140,13 @@ func TestHeartbeater_RemoveIdempotentAndWithoutAdd(t *testing.T) { // Never added: sub.bucket is nil, so Remove is a no-op. The stream handler's // deferred Remove must be safe even when Add was skipped (e.g. the wheel is // wired but the request bailed before registering). - never := NewSubscriber() + never := NewSubscriber(nil) assert.NotPanics(t, func() { hb.Remove(never) }) assert.Equal(t, 0, hb.Len()) // Added once, removed twice: the second Remove is a no-op, not a panic or a // negative count. - sub := NewSubscriber() + sub := NewSubscriber(nil) hb.Add(sub) hb.Remove(sub) assert.NotPanics(t, func() { hb.Remove(sub) }) @@ -173,7 +173,7 @@ func TestHeartbeater_ConcurrentAddRemovePush_Race(t *testing.T) { go func() { defer wg.Done() for range iterations { - sub := NewSubscriber() + sub := NewSubscriber(nil) hb.Add(sub) select { // drain whatever the wheel delivered, then leave case <-sub.Frames(): diff --git a/internal/stream/hub.go b/internal/stream/hub.go index 934556f4..11cebae2 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -26,7 +26,7 @@ type Hub struct { mu sync.RWMutex topics map[string]*topicRoutes policy *policy.Store // nil ⇒ policy filtering not configured (legacy passthrough) - registry *discovery.SchemaRegistry // nil ⇒ no column types; row-filter ordering compares lexicographically + registry *discovery.SchemaRegistry // nil ⇒ no column types; row-filter comparison degrades fail-closed (see columnKinds) metric *Metrics // nil-safe } @@ -37,9 +37,10 @@ type topicRoutes struct { // NewHub builds an event hub. A nil policy store passes every event through // unfiltered (the unwired-tests case); a non-nil store whose Get returns nil is a -// total lockout (a deleted/absent policy denies everyone). A nil registry disables -// numeric-aware row-filter comparison (ordering predicates fall back to -// lexicographic); metric may be nil. +// total lockout (a deleted/absent policy denies everyone). A nil registry leaves +// every column's type unknown, so row-filter comparison degrades FAIL-CLOSED: +// equality/set predicates admit only a byte-identical value and ordering/!= admit +// nothing (see policy.ColumnKind); metric may be nil. func NewHub(policyStore *policy.Store, registry *discovery.SchemaRegistry, metric *Metrics) *Hub { return &Hub{topics: make(map[string]*topicRoutes), policy: policyStore, registry: registry, metric: metric} } @@ -130,14 +131,14 @@ func (h *Hub) Broadcast(topic string, raw []byte) { } var evt ingest.EventMessage - decoded := json.Unmarshal(raw, &evt) == nil && evt.TableName != "" + decoded := decodeEvent(raw, &evt) p, filter := h.snapshotPolicy() - // Column types for numeric-aware row-filter comparison — resolved lazily at most + // Column kinds for type-aware row-filter comparison — resolved lazily at most // once per event, only when some role actually carries a row-filter, and reused // across every filtered role and subscriber. - var numericCols map[string]bool - numericResolved := false + var colKinds map[string]policy.ColumnKind + kindsResolved := false for _, rb := range roleBuckets { wire, perms, ok := projectColumns(p, filter, rb.role, &evt, raw, decoded) @@ -161,13 +162,14 @@ func (h *Hub) Broadcast(topic string, raw []byte) { // shared, but whether each subscriber may see THIS row depends on its claims, so // evaluate visibility per subscriber. Predicates read the full event data (a // filter may key on a column the role can't SELECT), not the projected columns. - if !numericResolved { - numericCols = h.numericCols(evt.TableName) - numericResolved = true + if !kindsResolved { + colKinds = h.columnKinds(evt.TableName) + kindsResolved = true } for _, sub := range rb.bucket.Snapshot() { subPerms := policy.Evaluate(p, rb.role, evt.TableName, "select", sub.claims) - if !subPerms.RowVisible(evt.Data, numericCols) { + if !subPerms.RowVisible(evt.Data, colKinds) { + h.metric.RowWithheld(evt.TableName, rb.role) continue // this row is filtered out for this subscriber } if !sub.Send(frame) { @@ -177,12 +179,14 @@ func (h *Hub) Broadcast(topic string, raw []byte) { } } -// numericCols maps each of the table's columns to whether its ClickHouse type is -// numeric, so the row-filter evaluator compares numeric columns numerically (9 < 100) -// rather than lexicographically. nil when no schema is available (unknown table, or a -// Hub built without a registry), in which case ordering predicates fall back to -// lexicographic comparison. -func (h *Hub) numericCols(table string) map[string]bool { +// columnKinds classifies each of the table's columns for the row-filter evaluator: +// numeric types compare numerically (9 < 100, matching ClickHouse), String compares +// bytewise (exactly ClickHouse's String collation), and any other type is omitted — +// policy.ColumnOpaque, the map's zero value — admitting byte-equality only. nil when +// no schema is available (unknown table, or a Hub built without a registry), which +// reads as every column Opaque: the fail-closed floor, never a lexicographic +// fallback that could admit rows the query path excludes ("9" > "100" as text). +func (h *Hub) columnKinds(table string) map[string]policy.ColumnKind { if h.registry == nil { return nil } @@ -190,13 +194,36 @@ func (h *Hub) numericCols(table string) map[string]bool { if schema == nil { return nil } - m := make(map[string]bool, len(schema.Columns)) + m := make(map[string]policy.ColumnKind, len(schema.Columns)) for _, c := range schema.Columns { - m[c.Name] = discovery.IsNumericType(c.Type) + switch { + case discovery.IsNumericType(c.Type): + m[c.Name] = policy.ColumnNumeric + case discovery.IsStringType(c.Type): + m[c.Name] = policy.ColumnText + } } return m } +// decodeEvent parses raw as a published EventMessage, reporting whether it is one +// (a decode error, trailing garbage, or a missing table name all read as "not an +// EventMessage", which projectColumns then fails closed under a policy). Numbers +// decode as json.Number — exact digit strings, not float64 — because the row-filter +// comparison must see the same value ClickHouse stores: ingest decodes with +// UseNumber and forwards the raw JSON to ClickHouse verbatim, so a bare 64-bit ID +// past 2^53 keeps its exact digits on the query path, and a lossy float64 decode +// here would collapse neighboring IDs into one value and deliver another tenant's +// row (the same reason ingest uses UseNumber). It also keeps the re-serialized wire +// frame byte-faithful for big integers. +func decodeEvent(raw []byte, evt *ingest.EventMessage) bool { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + // !More() rejects trailing content after the object, matching the strictness of + // the json.Unmarshal this replaces. + return dec.Decode(evt) == nil && !dec.More() && evt.TableName != "" +} + // snapshotPolicy returns the current policy and whether filtering is configured. // filter is false only when no store is wired (legacy passthrough); a wired store // returning a nil policy is a deliberate lockout that Evaluate denies. @@ -207,29 +234,44 @@ func (h *Hub) snapshotPolicy() (p *policy.Policy, filter bool) { return h.policy.Get(), true } -// ReplayFrame projects a single gap-fill event for one role+claims into a -// ready-to-write replay frame, or ok=false to skip it (denied table, invalid -// payload, or a row the claims aren't entitled to see). It is a method so replay -// shares the Hub's policy store and schema registry with the live fan-out — the -// handler can't accidentally project replay against a different (or nil) policy. The -// per-connection replay path uses this; the live path uses Broadcast. Replay is -// already per-connection, so it evaluates row-level security against this -// connection's claims directly. -func (h *Hub) ReplayFrame(role string, claims map[string]any, raw []byte) (Frame, bool) { - var evt ingest.EventMessage - decoded := json.Unmarshal(raw, &evt) == nil && evt.TableName != "" - p, filter := h.snapshotPolicy() - wire, perms, ok := projectColumns(p, filter, role, &evt, raw, decoded) - if !ok { - return Frame{}, false - } - if perms.HasRowFilter() { - subPerms := policy.Evaluate(p, role, evt.TableName, "select", claims) - if !subPerms.RowVisible(evt.Data, h.numericCols(evt.TableName)) { - return Frame{}, false // this row is filtered out for these claims +// ReplayProjector returns the projection function for one connection's gap-fill: +// each call projects a single replayed event for the connection's role+claims into +// a ready-to-write replay frame, or ok=false to skip it (denied table, invalid +// payload, or a row the claims aren't entitled to see). It is a Hub method so +// replay shares the Hub's policy store and schema registry with the live fan-out — +// the handler can't accidentally project replay against a different (or nil) +// policy. Replay is already per-connection, so row-level security evaluates against +// this connection's claims directly; the returned closure caches the per-table +// column-kind lookup across the replay loop (the same hoist Broadcast does per +// event), so a large Last-Event-ID gap-fill doesn't pay one registry lookup and map +// build per event. The closure is for a single goroutine — each connection makes +// its own. The live path uses Broadcast. +func (h *Hub) ReplayProjector(role string, claims map[string]any) func(raw []byte) (Frame, bool) { + var colKinds map[string]policy.ColumnKind + kindsFor := "" // table name colKinds was resolved for ("" ⇒ not yet resolved) + return func(raw []byte) (Frame, bool) { + var evt ingest.EventMessage + decoded := decodeEvent(raw, &evt) + p, filter := h.snapshotPolicy() + wire, perms, ok := projectColumns(p, filter, role, &evt, raw, decoded) + if !ok { + return Frame{}, false + } + if perms.HasRowFilter() { + // One topic ⇒ one table, so this resolves once per replay in practice; the + // guard re-resolves if a stream ever mixes tables rather than going stale. + if kindsFor != evt.TableName { + colKinds = h.columnKinds(evt.TableName) + kindsFor = evt.TableName + } + subPerms := policy.Evaluate(p, role, evt.TableName, "select", claims) + if !subPerms.RowVisible(evt.Data, colKinds) { + h.metric.RowWithheld(evt.TableName, role) + return Frame{}, false // this row is filtered out for these claims + } } + return Frame{Kind: KindReplay, Data: wire}, true } - return Frame{Kind: KindReplay, Data: wire}, true } // projectColumns applies role/table COLUMN policy to a decoded EventMessage (or diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index f07d92cf..7f8b2310 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -3,6 +3,7 @@ package stream import ( "context" "encoding/json" + "fmt" "strings" "sync" "testing" @@ -19,10 +20,11 @@ import ( ) // rawEvent marshals an EventMessage the way the ingest path publishes it. -func rawEvent(t *testing.T, table, ts string, data map[string]any) []byte { - t.Helper() +// testing.TB so the fan-out benchmark can build events too. +func rawEvent(tb testing.TB, table, ts string, data map[string]any) []byte { + tb.Helper() raw, err := json.Marshal(ingest.EventMessage{TableName: table, ReceivedTimestamp: ts, Data: data}) - require.NoError(t, err) + require.NoError(tb, err) return raw } @@ -68,7 +70,7 @@ func TestHub_ProjectsOncePerRole_FanOutToAllSubscribers(t *testing.T) { hub := NewHub(nil, nil, nil) // nil store ⇒ passthrough, no filtering const topic = "ingest.clicks" - a, b := NewSubscriber(), NewSubscriber() + a, b := NewSubscriber(nil), NewSubscriber(nil) hub.Add(topic, "public", a) hub.Add(topic, "public", b) @@ -100,8 +102,8 @@ func TestHub_ProjectsPerRole_ColumnFilterAndDenial(t *testing.T) { hub := NewHub(policy.NewMemoryStore(p), nil, nil) const topic = "ingest.clicks" - viewer := NewSubscriber() - blocked := NewSubscriber() + viewer := NewSubscriber(nil) + blocked := NewSubscriber(nil) hub.Add(topic, "viewer", viewer) hub.Add(topic, "blocked", blocked) @@ -136,7 +138,7 @@ func TestHub_ProjectsPerRole_DistinctRolesGetDistinctFrames(t *testing.T) { hub := NewHub(policy.NewMemoryStore(p), nil, nil) const topic = "ingest.clicks" - viewer, editor := NewSubscriber(), NewSubscriber() + viewer, editor := NewSubscriber(nil), NewSubscriber(nil) hub.Add(topic, "viewer", viewer) hub.Add(topic, "editor", editor) @@ -188,10 +190,8 @@ func TestHub_RowFilter_PerSubscriberIsolation(t *testing.T) { hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) const topic = "ingest.clicks" - acme := NewSubscriber() - acme.SetClaims(map[string]any{"tenant": "acme"}) - globex := NewSubscriber() - globex.SetClaims(map[string]any{"tenant": "globex"}) + acme := NewSubscriber(map[string]any{"tenant": "acme"}) + globex := NewSubscriber(map[string]any{"tenant": "globex"}) hub.Add(topic, "viewer", acme) hub.Add(topic, "viewer", globex) @@ -218,8 +218,7 @@ func TestHub_RowFilter_MissingColumn_FailsClosed(t *testing.T) { hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) const topic = "ingest.clicks" - acme := NewSubscriber() - acme.SetClaims(map[string]any{"tenant": "acme"}) + acme := NewSubscriber(map[string]any{"tenant": "acme"}) hub.Add(topic, "viewer", acme) hub.Broadcast(topic, rawEvent(t, "clicks", "2026-06-26T00:00:00Z", @@ -236,9 +235,8 @@ func TestHub_RowFilter_SharedProjectionAcrossSameClaims(t *testing.T) { hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) const topic = "ingest.clicks" - a, b := NewSubscriber(), NewSubscriber() - a.SetClaims(map[string]any{"tenant": "acme"}) - b.SetClaims(map[string]any{"tenant": "acme"}) + a := NewSubscriber(map[string]any{"tenant": "acme"}) + b := NewSubscriber(map[string]any{"tenant": "acme"}) hub.Add(topic, "viewer", a) hub.Add(topic, "viewer", b) @@ -254,7 +252,10 @@ func TestHub_RowFilter_SharedProjectionAcrossSameClaims(t *testing.T) { // TestHub_RowFilter_NumericOrdering_SchemaInformed drives the registry-backed path: // with a numeric column type in the schema, an `amount > 100` filter compares // numerically, so amount=9 is withheld (a lexicographic "9" > "100" comparison would -// have leaked it) and amount=250 is delivered. +// have leaked it) and amount=250 is delivered. Without a registry the same ordering +// filter has no type to trust and withholds every row — fail closed, never the +// lexicographic leak (the schemaless window is real: boot-time discovery failure +// retries in the background while the server serves). func TestHub_RowFilter_NumericOrdering_SchemaInformed(t *testing.T) { t.Parallel() reg := discovery.NewSchemaRegistryFromMap([]*discovery.TableSchema{ @@ -273,7 +274,7 @@ func TestHub_RowFilter_NumericOrdering_SchemaInformed(t *testing.T) { hub := NewHub(policy.NewMemoryStore(p), reg, nil) const topic = "ingest.clicks" - sub := NewSubscriber() // constant filter value ⇒ no claims needed + sub := NewSubscriber(nil) // constant filter value ⇒ no claims needed hub.Add(topic, "viewer", sub) hub.Broadcast(topic, rawEvent(t, "clicks", "t1", map[string]any{"amount": float64(9), "page": "/a"})) @@ -281,12 +282,22 @@ func TestHub_RowFilter_NumericOrdering_SchemaInformed(t *testing.T) { hub.Broadcast(topic, rawEvent(t, "clicks", "t2", map[string]any{"amount": float64(250), "page": "/b"})) assert.Equal(t, float64(250), frameData(t, recvFrame(t, sub))["data"].(map[string]any)["amount"]) + + // Same policy, no schema registry: an ordering predicate can't be proven either + // way, so both rows are withheld — including the one the schema-informed path + // delivers above. + noSchema := NewHub(policy.NewMemoryStore(p), nil, nil) + blind := NewSubscriber(nil) + noSchema.Add(topic, "viewer", blind) + noSchema.Broadcast(topic, rawEvent(t, "clicks", "t1", map[string]any{"amount": float64(9), "page": "/a"})) + noSchema.Broadcast(topic, rawEvent(t, "clicks", "t2", map[string]any{"amount": float64(250), "page": "/b"})) + assertNoFrame(t, blind) } func TestHub_TopicIsolation(t *testing.T) { t.Parallel() hub := NewHub(nil, nil, nil) - clicks, views := NewSubscriber(), NewSubscriber() + clicks, views := NewSubscriber(nil), NewSubscriber(nil) hub.Add("ingest.clicks", "public", clicks) hub.Add("ingest.views", "public", views) @@ -330,7 +341,7 @@ func TestHub_PassthroughAndFailClosed(t *testing.T) { t.Parallel() hub := NewHub(tt.store, nil, nil) const topic = "ingest.custom" - sub := NewSubscriber() + sub := NewSubscriber(nil) hub.Add(topic, "public", sub) hub.Broadcast(topic, []byte(tt.payload)) @@ -351,7 +362,7 @@ func TestHub_AddRemoveGCsBucketsAndTopics(t *testing.T) { t.Parallel() hub := NewHub(nil, nil, nil) const topic = "ingest.clicks" - sub := NewSubscriber() + sub := NewSubscriber(nil) hub.Add(topic, "public", sub) assert.Equal(t, 1, hub.Len(topic)) @@ -401,7 +412,7 @@ func TestHub_SlowConsumerDropIncrementsMetric(t *testing.T) { assert.Equal(t, int64(1), sumByName(rm, "wavehouse_sse_dropped_frames_total")) } -func TestHub_ReplayFrame(t *testing.T) { +func TestHub_ReplayProjector(t *testing.T) { t.Parallel() p := &policy.Policy{ Tables: map[string]policy.TablePolicy{ @@ -422,7 +433,7 @@ func TestHub_ReplayFrame(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - f, ok := hub.ReplayFrame(tt.role, nil, raw) + f, ok := hub.ReplayProjector(tt.role, nil)(raw) require.Equal(t, tt.want, ok) if !tt.want { return @@ -435,10 +446,10 @@ func TestHub_ReplayFrame(t *testing.T) { } } -// TestHub_ReplayFrame_RowFilter exercises the row-filter branch of ReplayFrame: the +// TestHub_ReplayProjector_RowFilter exercises the row-filter branch of replay: the // #319 fix applies row-level security on the per-connection replay path too, so a // gap-fill event is projected only when the connection's claims satisfy the filter. -func TestHub_ReplayFrame_RowFilter(t *testing.T) { +func TestHub_ReplayProjector_RowFilter(t *testing.T) { t.Parallel() hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) raw := rawEvent(t, "clicks", "2026-06-26T00:00:00Z", @@ -446,17 +457,24 @@ func TestHub_ReplayFrame_RowFilter(t *testing.T) { t.Run("matching claims replay the row, projected to allowed columns", func(t *testing.T) { t.Parallel() - f, ok := hub.ReplayFrame("viewer", map[string]any{"tenant": "acme"}, raw) + project := hub.ReplayProjector("viewer", map[string]any{"tenant": "acme"}) + f, ok := project(raw) require.True(t, ok) assert.Equal(t, KindReplay, f.Kind) inner := frameData(t, f)["data"].(map[string]any) assert.Equal(t, "/a", inner["page"]) assert.NotContains(t, inner, "secret", "denied column stripped on replay too") + + // The projector is reusable across a replay loop: a second event through the + // same closure (cached column kinds) projects identically. + f2, ok2 := project(raw) + require.True(t, ok2) + assert.Equal(t, f.Data, f2.Data) }) t.Run("non-matching claims withhold the row", func(t *testing.T) { t.Parallel() - _, ok := hub.ReplayFrame("viewer", map[string]any{"tenant": "globex"}, raw) + _, ok := hub.ReplayProjector("viewer", map[string]any{"tenant": "globex"})(raw) require.False(t, ok, "row must be withheld when claims don't satisfy the filter") }) } @@ -473,7 +491,7 @@ func TestHub_ConcurrentAddRemoveBroadcast_Race(t *testing.T) { go func(role string) { defer wg.Done() for range 50 { - sub := NewSubscriber() + sub := NewSubscriber(nil) hub.Add(topic, role, sub) hub.Broadcast(topic, raw) hub.Remove(topic, role, sub) @@ -484,6 +502,150 @@ func TestHub_ConcurrentAddRemoveBroadcast_Race(t *testing.T) { assert.Equal(t, 0, hub.Len(topic), "every subscriber is removed") } +// TestHub_ConcurrentRowFilteredBroadcast_Race is the row-filter twin of the +// passthrough race test above: with a filtered role, the fan-out goroutine reads +// each subscriber's claims (hub.Broadcast → sub.claims) while other goroutines +// construct, register and remove claims-bearing subscribers. Claims are immutable +// after construction, and publication happens-before the fan-out read via the +// bucket mutex in Add — this test makes the race detector watch exactly that edge, +// so a future claims setter (or any post-Add mutation) fails -race here instead of +// racing silently on a security decision. +func TestHub_ConcurrentRowFilteredBroadcast_Race(t *testing.T) { + t.Parallel() + hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) + const topic = "ingest.clicks" + raw := rawEvent(t, "clicks", "t", map[string]any{"tenant_id": "acme", "page": "/a"}) + + var wg sync.WaitGroup + for range 4 { // broadcasters: per-subscriber claims evaluation on every event + wg.Add(1) + go func() { + defer wg.Done() + for range 100 { + hub.Broadcast(topic, raw) + } + }() + } + for i := range 4 { // churners: subscribers with claims come and go concurrently + wg.Add(1) + go func(tenant string) { + defer wg.Done() + for range 50 { + sub := NewSubscriber(map[string]any{"tenant": tenant}) + hub.Add(topic, "viewer", sub) + hub.Remove(topic, "viewer", sub) + } + }([]string{"acme", "globex"}[i%2]) + } + wg.Wait() + assert.Equal(t, 0, hub.Len(topic), "every subscriber is removed") +} + +// TestHub_RowFilter_BigIntegerExact: a bare JSON integer past 2^53 must keep its +// exact digits through the hub's decode (UseNumber), or the row filter compares a +// lossily-rounded value: tenant 10000000000000001's row would falsely equal a +// tenant claim of 10000000000000000 — float64 collapses the neighbors — and be +// delivered cross-tenant on the stream while the query path (ClickHouse stores the +// exact digits ingest forwarded) excludes it. The raw payload is hand-built — +// marshaling a Go float64 would already have destroyed the value this test is about. +func TestHub_RowFilter_BigIntegerExact(t *testing.T) { + t.Parallel() + reg := discovery.NewSchemaRegistryFromMap([]*discovery.TableSchema{ + {Name: "clicks", Columns: []discovery.Column{ + {Name: "tenant_id", Type: "UInt64"}, + {Name: "page", Type: "String"}, + }}, + }) + p := &policy.Policy{ + Tables: map[string]policy.TablePolicy{ + "clicks": {Select: map[string]policy.RolePermissions{ + "viewer": {Filter: map[string]policy.Filter{"tenant_id": {Eq: new("{{ jwt.tenant }}")}}}, + }}, + }, + } + hub := NewHub(policy.NewMemoryStore(p), reg, nil) + const topic = "ingest.clicks" + + neighbor := NewSubscriber(map[string]any{"tenant": "10000000000000000"}) // float64-equal neighbor + exact := NewSubscriber(map[string]any{"tenant": "10000000000000001"}) + hub.Add(topic, "viewer", neighbor) + hub.Add(topic, "viewer", exact) + + raw := []byte(`{"table_name":"clicks","received_timestamp":"t","data":{"tenant_id":10000000000000001,"page":"/a"}}`) + hub.Broadcast(topic, raw) + + assertNoFrame(t, neighbor) + frame := recvFrame(t, exact) + assert.Contains(t, string(frame.Data), "10000000000000001", + "the wire frame carries the exact digits, not a float64 rounding") +} + +// TestHub_RowFilterWithheldIncrementsMetric: a row withheld by row-level security is +// otherwise invisible to operators (it is not a dropped frame — the queue was never +// tried). The wavehouse_sse_rows_withheld_total counter must tick for live fan-out +// and replay withholds alike, so "no matching rows" and "a filter is withholding +// everything" are distinguishable. +func TestHub_RowFilterWithheldIncrementsMetric(t *testing.T) { + // No t.Parallel(): NewMetrics binds the global meter provider, swapped here. + savedMP := otel.GetMeterProvider() + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + otel.SetMeterProvider(mp) + t.Cleanup(func() { + _ = mp.Shutdown(context.Background()) + otel.SetMeterProvider(savedMP) + }) + + hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, NewMetrics()) + const topic = "ingest.clicks" + acme := NewSubscriber(map[string]any{"tenant": "acme"}) + globex := NewSubscriber(map[string]any{"tenant": "globex"}) + hub.Add(topic, "viewer", acme) + hub.Add(topic, "viewer", globex) + + raw := rawEvent(t, "clicks", "t", map[string]any{"tenant_id": "acme", "page": "/a"}) + hub.Broadcast(topic, raw) // delivered to acme, withheld from globex → 1 + + _, ok := hub.ReplayProjector("viewer", map[string]any{"tenant": "globex"})(raw) + require.False(t, ok) // replay withhold → 2 + + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &rm)) + assert.Equal(t, int64(2), sumByName(rm, "wavehouse_sse_rows_withheld_total")) + assert.NotEmpty(t, recvFrame(t, acme).Data, "the entitled subscriber still gets the event") + assertNoFrame(t, globex) +} + +// BenchmarkBroadcast_RowFilteredFanout measures the per-subscriber cost a +// row-filtered role pays on the delivery hot path (#294/#353 vs #319): each +// subscriber's claims run through policy.Evaluate + RowVisible per event, where an +// unfiltered role shares one projection bucket-wide. Half the subscribers share the +// event's tenant (row visible), half don't (row withheld); either way each pays the +// per-subscriber evaluation, which is the cost under measurement. See #435 for the +// memoization follow-up this benchmark exists to arbitrate. +func BenchmarkBroadcast_RowFilteredFanout(b *testing.B) { + const topic = "ingest.clicks" + raw := rawEvent(b, "clicks", "2026-06-26T00:00:00Z", + map[string]any{"tenant_id": "acme", "page": "/a", "secret": "x"}) + + for _, n := range []int{100, 1_000, 10_000} { + b.Run(fmt.Sprintf("subscribers=%d", n), func(b *testing.B) { + hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) + for i := range n { + tenant := "acme" + if i%2 == 1 { + tenant = "globex" + } + hub.Add(topic, "viewer", NewSubscriber(map[string]any{"tenant": tenant})) + } + b.ReportAllocs() + for b.Loop() { + hub.Broadcast(topic, raw) + } + }) + } +} + func TestWireFrame(t *testing.T) { t.Parallel() tests := []struct { diff --git a/internal/stream/metrics.go b/internal/stream/metrics.go index 0059bb80..434fe297 100644 --- a/internal/stream/metrics.go +++ b/internal/stream/metrics.go @@ -25,6 +25,7 @@ type Metrics struct { frames metric.Int64Counter bytes metric.Int64Counter dropped metric.Int64Counter + withheld metric.Int64Counter } // NewMetrics builds the SSE instruments on the global meter provider. Call it @@ -42,7 +43,9 @@ func NewMetrics() *Metrics { metric.WithDescription("SSE bytes written to clients"), metric.WithUnit("By")) dropped, _ := meter.Int64Counter("wavehouse_sse_dropped_frames_total", metric.WithDescription("SSE frames dropped to a full subscriber queue (slow consumer)")) - return &Metrics{active: active, duration: duration, frames: frames, bytes: bytes, dropped: dropped} + withheld, _ := meter.Int64Counter("wavehouse_sse_rows_withheld_total", + metric.WithDescription("Event rows withheld from a subscriber by the role's row-level-security filter (including fail-closed evaluations)")) + return &Metrics{active: active, duration: duration, frames: frames, bytes: bytes, dropped: dropped, withheld: withheld} } // ConnOpened records a newly established stream. @@ -81,3 +84,18 @@ func (m *Metrics) FrameDropped(kind string) { } m.dropped.Add(context.Background(), 1, metric.WithAttributes(attribute.String("kind", kind))) } + +// RowWithheld records one event row withheld from one subscriber (live or replay) +// because the role's row-level-security filter did not admit it — including every +// fail-closed evaluation: a filter column absent from the event, a non-scalar or +// unparseable value, and a column with no usable schema. Labeled by table and role +// (bounded by the policy, not by data) so an operator can tell "no matching rows" +// from "a misconfigured filter is withholding everything for this role" — without +// it, a fail-closed security feature is indistinguishable from an idle stream. +func (m *Metrics) RowWithheld(table, role string) { + if m == nil { + return + } + m.withheld.Add(context.Background(), 1, + metric.WithAttributes(attribute.String("table", table), attribute.String("role", role))) +} diff --git a/internal/stream/subscriber.go b/internal/stream/subscriber.go index 3a22b909..b260306b 100644 --- a/internal/stream/subscriber.go +++ b/internal/stream/subscriber.go @@ -25,9 +25,13 @@ type Subscriber struct { // claims are this connection's JWT claims, evaluated against a role's row-level // security filter so the Hub can decide, per subscriber, whether each event row is - // visible (see Hub.Broadcast). Set once via SetClaims before Add, then read-only — - // so the fan-out goroutine reads it without synchronization. nil ⇒ no claims (a - // tokenless subscriber), which fails any claim-scoped row-filter closed. + // visible (see Hub.Broadcast). Fixed at construction — claims are a property of the + // authenticated connection, and there is deliberately no setter, so the fan-out + // goroutine's unsynchronized read is race-free structurally (publication happens + // under the bucket mutex in Hub.Add). A policy reload needs no claims update: the + // Hub re-reads the policy store on every event, and claims only feed Evaluate. + // nil ⇒ no claims (a tokenless subscriber), which fails any claim-scoped + // row-filter closed. claims map[string]any // evict is closed (once) to ask the owning handler to disconnect a wedged slow @@ -39,8 +43,15 @@ type Subscriber struct { } // NewSubscriber returns a Subscriber ready to register with a Heartbeater and the -// event Hub. -func NewSubscriber() *Subscriber { return newSubscriber(defaultSubscriberQueue) } +// event Hub, carrying the connection's JWT claims (nil for a tokenless caller). The +// Hub evaluates the claims against a role's row-level-security filter to decide, +// per subscriber, whether each event row is visible; taking them here — with no +// setter — is what makes the claims immutable for the connection's lifetime. +func NewSubscriber(claims map[string]any) *Subscriber { + s := newSubscriber(defaultSubscriberQueue) + s.claims = claims + return s +} // newSubscriber builds a Subscriber with an explicit queue size — the seam tests // use to exercise the full-queue/drop path without enqueuing 64 frames. @@ -48,12 +59,6 @@ func newSubscriber(size int) *Subscriber { return &Subscriber{out: make(chan Frame, size), evict: make(chan struct{})} } -// SetClaims attaches this connection's JWT claims, which the Hub evaluates against a -// role's row-level-security filter to decide, per subscriber, whether each event row -// is visible. Call it before registering the subscriber with the Hub; the claims are -// then read-only for the subscriber's lifetime, so the fan-out reads them race-free. -func (s *Subscriber) SetClaims(claims map[string]any) { s.claims = claims } - // Frames is the queue of ready-to-write frames; the handler writes whatever // arrives here to the client verbatim. func (s *Subscriber) Frames() <-chan Frame { diff --git a/internal/stream/subscriber_test.go b/internal/stream/subscriber_test.go index b5cfe8d0..740f1e59 100644 --- a/internal/stream/subscriber_test.go +++ b/internal/stream/subscriber_test.go @@ -29,7 +29,7 @@ func TestSubscriber_SendDeliversThenDropsWhenFull(t *testing.T) { func TestSubscriber_EvictedIsOpenUntilClosed(t *testing.T) { t.Parallel() - sub := NewSubscriber() + sub := NewSubscriber(nil) // The eviction seam is inert until the slow-consumer follow-up closes it: the // channel stays open, so a non-blocking read finds nothing. From 3163b2ede17e82a3b1882643435b2fa4c00b2ddb Mon Sep 17 00:00:00 2001 From: taitelee Date: Tue, 11 Aug 2026 17:05:07 -0400 Subject: [PATCH 12/27] docs(stream): tighten row-filter docs per review; prove e2e stream absence with barrier rows --- SECURITY.md | 2 +- docs/src/content/docs/access-control.mdx | 14 ++++++++++---- docs/src/content/docs/api.md | 6 +++--- docs/src/content/docs/architecture.md | 4 ++-- docs/src/content/docs/sdk/reference.md | 3 ++- docs/src/content/docs/sdk/streaming.md | 17 ++++++++++++++++- tests/e2e/sdk/streaming.test.ts | 24 +++++++++++++++++++++--- 7 files changed, 55 insertions(+), 15 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 4ef0f151..6096303c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,7 +25,7 @@ We will acknowledge receipt within 48 hours and aim to provide an initial assess WaveHouse handles data and enforces strict isolation: - **JWT validation**: The JWT middleware always runs (there is no on/off switch). Signing supports either an HMAC shared secret or a remote JWKS endpoint (`auth.jwks_url`). Accepted signing algorithms are restricted to the configured verifier's family — HMAC accepts only `HS256/384/512`, JWKS only the asymmetric set (`RS*`/`ES*`/`PS*`/`EdDSA`) — and the token's `alg` header is validated before any key material is used, so `alg: none` and algorithm-confusion attacks (re-signing with `HS256` against a JWKS deployment's public key) are rejected. A request with no token, or an invalid/expired one, falls back to the policy `default_role`; elevated access requires a valid token, and a denied request that carried a bad token fails loud (`401`) rather than as a bare `403`. -- **Role-based access control**: Roles are extracted from a configurable JWT claim path. Non-admin roles have per-table, per-column, row-level policies enforced on ingest, query, and the live SSE stream (the stream's in-memory row-filter comparison has a documented fail-closed boundary — see the access-control docs); the admin role (`policy.admin_role`, `"admin"` by default, exact case-sensitive match) bypasses them. The configured non-JWT operator key (`auth.operator_key`) likewise bypasses per-role policy — a matching request is authorized as a full-access platform operator without a JWT; treat it as an admin secret. A request presenting a *non-matching* operator key is logged at `WARN` and counted by `wavehouse_auth_operator_key_failures_total`, so probing of that credential is observable and alertable. +- **Role-based access control**: Roles are extracted from a configurable JWT claim path. Non-admin roles have per-table, per-column, row-level policies enforced on ingest, query, and the live SSE stream (the stream's in-memory row-filter comparison has a documented fail-closed boundary — see the [access-control docs](https://wavehouse.dev/access-control#where-each-rule-is-enforced)); the admin role (`policy.admin_role`, `"admin"` by default, exact case-sensitive match) bypasses them. The configured non-JWT operator key (`auth.operator_key`) likewise bypasses per-role policy — a matching request is authorized as a full-access platform operator without a JWT; treat it as an admin secret. A request presenting a *non-matching* operator key is logged at `WARN` and counted by `wavehouse_auth_operator_key_failures_total`, so probing of that credential is observable and alertable. - **Input validation**: JSON payloads are validated against ClickHouse schemas before processing. - **Query passthrough**: Raw SQL via `POST /v1/admin/query` is restricted to the admin role — the same `RequireAdmin` gate as the rest of `/v1/admin/*`. A request with no/invalid token resolves to the `default_role`, which in a production config is not the admin role (setting `default_role` equal to the admin role is a loudly-warned, dev-only escape hatch), so it cannot reach this endpoint — the one exception is a request presenting the configured `auth.operator_key`, which reaches the whole `/v1/admin/*` surface (including this endpoint) without a JWT and even under a deleted policy, so treat that key as an admin secret. Raw SQL has no per-statement scope check (a full SQL parser would be needed to authorize predicates), so the role gate is the entire authorization story. Non-admin callers use structured queries (`POST /v1/query?table={table}`, validated against schema with permission injection) or named pipes (`GET/POST /v1/pipes/{name}`); raw-SQL grants to non-admin roles via the policy engine are no longer supported (the `raw_sql` field on policies has been removed). - **Supply chain**: Third-party GitHub Actions are pinned to full commit SHAs (enforced by the repository's Actions settings — `sha_pinning_required`). `govulncheck` runs on every push/PR. Dependabot opens weekly grouped PRs for Go modules, GitHub Actions, and the npm packages — one grouped PR covering the docs site, TS SDK, and E2E tests via the root pnpm workspace. Released artifacts ship signed [Sigstore](https://www.sigstore.dev/) build-provenance attestations — verify the container image with `gh attestation verify oci://ghcr.io/wave-rf/wavehouse: --repo Wave-RF/WaveHouse`, a downloaded release-binary archive with `gh attestation verify --repo Wave-RF/WaveHouse`, and the `@wavehouse/sdk` package via its npm provenance badge or `npm audit signatures`. (Provenance covers the published binaries and image, not `go install`, which compiles from source.) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index c64b0cc2..9b284931 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -375,14 +375,20 @@ The same policy drives every data path, but not every field is meaningful on eve | Named pipe | `GET/POST /v1/pipes/{name}` | per-pipe `allowed_roles` (not the policy engine; see [Named Pipes](/pipes)). Resource limits come from ClickHouse's [server-wide settings](/configuration#server-side-resource-limits), not per-role policy caps | :::caution[Live streams enforce column and row policy, but not resource limits] -SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. The stream evaluates predicates in memory, without ClickHouse's per-type coercion, so how much it can enforce depends on what the schema says about the filtered column — and every case it cannot decide **fails closed**: ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide. +SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Predicates are evaluated against the **full ingested event**, so a filter may key on a column the role can't `select` (denied columns are still stripped from what's delivered). The stream evaluates predicates in memory, without ClickHouse's per-type coercion, so how much it can enforce depends on what the schema says about the filtered column — and every case it cannot decide **fails closed**: ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide — with one payload-vs-stored exception, insert-time numeric narrowing, called out below. -- **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a string-encoded 64-bit ID never falsely matches a neighbor); an unparseable or `NaN` operand withholds the row. +- **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a 64-bit ID never falsely matches a neighbor, whether it arrives string-encoded or as a bare JSON number); an unparseable or `NaN` operand withholds the row. - **`String` columns** (again under any `Nullable`/`LowCardinality` wrapping): byte comparison *is* ClickHouse's String comparison — equality and ordering are both exact. - **Every other type** (`Enum`, `UUID`, `Date`/`DateTime`, `Bool`, `IPv4`/`IPv6`, `FixedString`, …): only byte-equality is trusted. `_eq`/`_in` admit exactly the event's own text rendering — write the filter value the way your events carry it (`true`, not `1`; a lowercase UUID if that's what clients send). `_neq`, `_gt` and `_lt` withhold **every** row: a text difference can be pure representation (an uppercase UUID, an alternate date format, an Enum name vs. its number), so inequality and order are unprovable without ClickHouse — on these columns, use the query path for ordering/exclusion filters. - **No usable schema** — the table is unknown to schema discovery, or discovery is still failing at boot (the server serves while retrying in the background): every column is treated as the "other" bucket above. Equality scoping keeps working; ordering and `_neq` withhold until a schema is available. -Two more fail-closed edges worth knowing when you write a policy. The stream evaluates the **ingested event payload**, not the stored row — so a filter keyed on a column the payload doesn't carry withholds *every* event for that subscriber, even though the same filter matches normally on the query path. That bites a `MATERIALIZED`/`ALIAS` column (never part of an ingest payload) or a `DEFAULT` column your clients omit. The recommended [`check` + `filter` pairing](#insert-checks) is unaffected: an `_eq` insert `check` auto-injects its claim value into any payload that omits the column *before* the event is published, so the streamed event carries it and the matching row filter evaluates normally. And any non-scalar event value (array/object/null) under a filtered column withholds the row. The payload-vs-stored distinction also bounds the fail-closed guarantee itself: it holds for the *payload* value, and the one place that differs from the stored row is insert-time numeric narrowing — a payload with more precision than the column's declared type (a `Decimal`'s scale, `Float32` width) is rounded on insert, so a numeric *threshold* filter can deliver an event whose stored row rounds to the other side of the boundary. Equality scoping on identity columns (integer IDs, `String` tenants) is not affected — those store exactly. Rows withheld by row-level security are counted in `wavehouse_sse_rows_withheld_total` (labeled by table and role) — check it before concluding a quiet stream simply has no matching rows. +A few more edges worth knowing when you write a policy — the stream evaluates the **ingested event payload**, not the stored row, and each of these follows from that: + +- **A filtered column the payload doesn't carry withholds *every* event** for that subscriber, even though the same filter matches normally on the query path. That bites a `MATERIALIZED`/`ALIAS` column (never part of an ingest payload) or a `DEFAULT` column your clients omit. The recommended [`check` + `filter` pairing](#insert-checks) is unaffected: an `_eq` insert `check` auto-injects its claim value into any payload that omits the column *before* the event is published, so the streamed event carries it and the matching row filter evaluates normally. +- **A non-scalar event value** (array/object/null) under a filtered column withholds the row. +- **Insert-time numeric narrowing bounds the fail-closed guarantee itself** — this one edge can fail *open*. The guarantee holds for the *payload* value, and the one place that differs from the stored row is a payload with more precision than the column's declared type (a `Decimal`'s scale, `Float32` width), which is rounded on insert — so a numeric filter (equality or ordering alike) on such a column compares the pre-rounding payload, and can deliver an event whose stored row lands on the other side of the comparison: an `_eq "1.1"` on a `Float32` column matches the payload `1.1` on the stream while the query path compares against the stored `1.10000002…`. Equality scoping on identity columns (integer IDs, `String` tenants) is not affected — those store exactly. + +Rows withheld by row-level security are counted in `wavehouse_sse_rows_withheld_total` (labeled by table and role) — check it before concluding a quiet stream simply has no matching rows. The resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) remain a property of the SQL query path and are **not** applied to the live event stream; if those caps are part of a role's isolation story, don't rely on them over the stream. ::: @@ -528,7 +534,7 @@ Per-role permissions (`tables.
.select.` and `.insert.`): | ----- | ---- | ---------- | ----------- | | `allow_columns` | string[] | select, insert | Allowlist of columns. Empty or `["*"]` = all columns (minus `deny_columns`). | | `deny_columns` | string[] | select, insert | Blocklist of columns. Always wins over `allow_columns`. | -| `filter` | map | select | Row-level `WHERE` predicates (`_eq`/`_neq`/`_gt`/`_lt`/`_in`), ANDed together. `_in` takes a single array-valued claim → `col IN (…)`. Values support `{{ jwt.path }}` templating. | +| `filter` | map | select | Row-level predicates (`_eq`/`_neq`/`_gt`/`_lt`/`_in`), ANDed together — injected as a SQL `WHERE` on structured reads and evaluated per subscriber on the live stream (see [enforcement](#where-each-rule-is-enforced)). `_in` takes a single array-valued claim → `col IN (…)`. Values support `{{ jwt.path }}` templating. | | `check` | map | insert | Required insert values (`_eq`, or `_in` for a claim-derived set; `_neq`/`_gt`/`_lt` are rejected). `_eq` is enforced if present and auto-injected if absent; `_in` requires the column be present and in-set. Supports templating. | | `allowed_aggregations` | string[] | select | Allowlist of aggregation functions. Empty = all (minus denied). Case-insensitive. | | `denied_aggregations` | string[] | select | Blocklist of aggregation functions. Always wins. | diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index 6cb14d25..f5952430 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -211,10 +211,10 @@ The body is a **flat JSON object** whose keys must match column names in the tar **Schema Validation:** - Unknown fields (not in the ClickHouse schema) are rejected. -- Type mismatches are rejected (e.g., sending a string for a `Float64` column). +- Type mismatches are rejected (e.g., sending a JSON object for a `Float64` column). - Missing required columns (non-nullable without a default) are rejected. - Null values for non-nullable columns are rejected. -- Type compatibility: `String`/`DateTime`/`UUID`/`Enum`/`IPv*` accept JSON strings; `Int*`/`Float*`/`Decimal` accept JSON numbers; `Bool` accepts JSON booleans or numbers; `Array` accepts JSON arrays; `Map`/`Tuple` accept JSON objects. +- Type compatibility: `Int*`/`Float*`/`Decimal*` accept JSON numbers **or numeric strings** — the string form exists so 64-bit IDs survive JavaScript's `Number` precision loss; `String`/`FixedString`/`UUID` accept strings, numbers, or booleans (coerced); `Date*`/`DateTime*`, `Enum8/16`, and `IPv4`/`IPv6` accept strings or numbers; `Bool` accepts booleans, numbers (`0`/`1`), or strings (`"true"`/`"false"`); `Array` accepts JSON arrays; `Map` accepts JSON objects; `Tuple` accepts arrays or objects. A ClickHouse type outside this matrix accepts any value and defers validation to ClickHouse. - `Nullable()` and `LowCardinality()` wrappers are handled transparently. **Response (accepted):** @@ -539,7 +539,7 @@ data: {"table_name":"clicks","received_timestamp":"2026-03-24T12:00:01.456Z","da Each SSE connection is bound to a single `?table=`; to consume multiple tables, open one connection per table. -**Note:** When access control policies are active, streamed events are filtered per the caller's role — tables without select permission are skipped, denied columns are removed from each event, and the role's row-level `filter` is evaluated per subscriber against the caller's JWT claims, so each connection receives only the rows it could also read over `POST /v1/query`. The claims come from the connection's token (the `Authorization` header, or the `?token=` fallback above), and replayed gap-fill events are filtered the same way. See [Access control — row-level security](/access-control#row-level-security) and the enforcement caution there for the stream's fail-closed comparison boundary. +**Note:** When access control policies are active, streamed events are filtered per the caller's role — tables without select permission are skipped, denied columns are removed from each event, and the role's row-level `filter` is evaluated per subscriber against the caller's JWT claims — so a connection is never delivered a row the query path would hide for that role, with one documented exception (a numeric filter over a column type that rounds the payload on insert). The claims come from the connection's token (the `Authorization` header, or the `?token=` fallback above), and replayed gap-fill events are filtered the same way. See [Access control — row-level security](/access-control#row-level-security) and the enforcement caution there for the stream's fail-closed comparison boundary. **CORS:** `/v1/stream` honors the `server.cors_allowed_origins` allowlist like every endpoint, so a browser `EventSource` from an allowed origin connects normally. `Last-Event-ID` is allow-listed in the CORS preflight so fetch-based clients can resume cross-origin. diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index 4601f1cf..f1738ff9 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -86,10 +86,10 @@ The API layer uses [Chi](https://github.com/go-chi/chi) for routing with Request The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)) lives next to the keepalive primitives it shares. One abstraction per file. - **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the type-aware comparison seeded from the schema registry — `policy.ColumnKind`: numeric columns compare numerically, `String` bytewise, everything else admits byte-equality only and fails ordering/`!=` closed, so a missing schema can never downgrade the comparison to a leak). Each row withheld this way increments `wavehouse_sse_rows_withheld_total`. This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayProjector` shares the same projection and per-connection row check for the handler's gap-fill, caching the per-table column-kind lookup across the replay loop. -- **subscriber.go** — `Subscriber`, the per-connection handle. It owns a single ready-to-write outbound queue of `Frame`s (each tagged with its `kind`, so the handler labels the write where it happens): producers — the keepalive wheel and the event `Hub` — fan frames in with `Send` (non-blocking; a full queue drops and the `Hub` counts it), and the handler drains `Frames()` to the client verbatim. The queue is sized for buffering live events (cap 64, up from the keepalive-only cap 1; #152 will make it a knob), and an `Evicted()` channel is the seam the slow-consumer follow-up closes to disconnect a wedged consumer. +- **subscriber.go** — `Subscriber`, the per-connection handle. It carries the connection's JWT claims, fixed at construction (`NewSubscriber(claims)`, no setter) — the claims the `Hub` resolves a role's row-level `filter` against, and immutability is what makes the fan-out's unsynchronized claims read race-free structurally. It owns a single ready-to-write outbound queue of `Frame`s (each tagged with its `kind`, so the handler labels the write where it happens): producers — the keepalive wheel and the event `Hub` — fan frames in with `Send` (non-blocking; a full queue drops and the `Hub` counts it), and the handler drains `Frames()` to the client verbatim. The queue is sized for buffering live events (cap 64, up from the keepalive-only cap 1; #152 will make it a knob), and an `Evicted()` channel is the seam the slow-consumer follow-up closes to disconnect a wedged consumer. - **bucket.go** — `Bucket`, the reusable fan-out primitive: a concurrency-safe set of subscribers. `Push` delivers a shared `Frame` to each fire-and-forget (the keepalive wheel's ring); `Snapshot` exposes the members so the event `Hub` can fan out while inspecting each `Send` result (to count drops). The `Hub` holds one `Bucket` per `(topic, role)` so a projected frame is built once and sent to every member instead of re-projected per subscriber. - **heartbeat.go** — The keepalive wheel (`Heartbeater`). A single process-wide ticker fans a minimal `:` comment across the ring of `Bucket`s, waking ~1/N of live streams per tick so the writes don't synchronize. The effective per-connection keepalive period is `stream.keepalive_interval` (the wheel ticks every `keepalive_interval ÷ keepalive_buckets`, so one rotation spans the interval); the owning handler goroutine does the actual write, so the shared ticker never touches a `ResponseWriter` directly. -- **metrics.go** — `Metrics`, the SSE instrument set: `wavehouse_sse_active_streams` (open streams), `wavehouse_sse_stream_duration_seconds` (lifetime), `wavehouse_sse_frames_sent_total` / `wavehouse_sse_bytes_sent_total` (labeled by `kind`: `keepalive`, `event`, `replay`), `wavehouse_sse_dropped_frames_total` (frames dropped to a full subscriber queue — the slow-consumer signal that was silent before #294), and `wavehouse_sse_rows_withheld_total` (rows withheld from a subscriber by the row-level-security filter, labeled by table and role — the signal that separates "no matching rows" from "a fail-closed filter is withholding everything"). Nil-safe, so the handler holds one unconditionally and tests skip wiring it; one shared instance records both the handler's write sites and the `Hub`'s drop counts. Separate from `observability.RegisterSystemMetrics`, which covers only the NATS/Pebble system gauges. Streams are observed through these metrics rather than per-event traces (the router excludes `/v1/stream` from the HTTP tracer). +- **metrics.go** — `Metrics`, the SSE instrument set: `wavehouse_sse_active_streams` (open streams), `wavehouse_sse_stream_duration_seconds` (lifetime), `wavehouse_sse_frames_sent_total` / `wavehouse_sse_bytes_sent_total` (labeled by `kind`: `keepalive`, `event`, `replay`), `wavehouse_sse_dropped_frames_total` (frames dropped to a full subscriber queue — the slow-consumer signal that was silent before #294), and `wavehouse_sse_rows_withheld_total` (rows withheld from a subscriber by the row-level-security filter, labeled by table and role — the signal that separates "no matching rows" from "a fail-closed filter is withholding everything"). Nil-safe, so the handler holds one unconditionally and tests skip wiring it; one shared instance records both the handler's write sites and the `Hub`'s drop and row-withheld counts. Separate from `observability.RegisterSystemMetrics`, which covers only the NATS/Pebble system gauges. Streams are observed through these metrics rather than per-event traces (the router excludes `/v1/stream` from the HTTP tracer). ### `auth/` — Authentication diff --git a/docs/src/content/docs/sdk/reference.md b/docs/src/content/docs/sdk/reference.md index 39b42d52..5b5a66a0 100644 --- a/docs/src/content/docs/sdk/reference.md +++ b/docs/src/content/docs/sdk/reference.md @@ -87,6 +87,7 @@ createClient(config) → WaveHouseClient StreamController (NOT thenable) ├── .subscribe({ next, status?, error? }) → unsubscribe() +├── .connected(timeoutMs?) → Promise ├── .close() ├── .status → StreamStatus └── [Symbol.asyncIterator]() → AsyncIterableIterator @@ -109,7 +110,7 @@ Codegen reads `/v1/schema`, which is **admin-only**. Against a non-dev server, p | Flag | Description | Default | |------|-------------|---------| -| `--url`, `-u` | WaveHouse base URL | `http://localhost:8080` | +| `--url`, `-u` | WaveHouse base URL (may include a path prefix) | `http://localhost:8080` | | `--out`, `-o` | Output .d.ts file path | `./wavehouse.d.ts` | | `--auth`, `-a` | Bearer token (if auth required) | — | diff --git a/docs/src/content/docs/sdk/streaming.md b/docs/src/content/docs/sdk/streaming.md index 2a9868a5..700bb512 100644 --- a/docs/src/content/docs/sdk/streaming.md +++ b/docs/src/content/docs/sdk/streaming.md @@ -67,6 +67,17 @@ stream.close(); Current connection status: `'connecting' | 'live' | 'reconnecting' | 'closed'`. +### `.connected(timeoutMs?)` + +Returns a promise that resolves once `.status` reaches `'live'` — the "safe to ingest now" barrier, so tests and scripts don't need sleep loops between opening a stream and inserting the rows they expect to see. Rejects if the stream is already `'closed'`, or after `timeoutMs` milliseconds (default `5_000`) if it never connects. Safe to call before `.subscribe()`. + +```ts +const stream = wh.from('clicks').stream(); +const unsub = stream.subscribe({ next: (e) => console.log(e) }); +await stream.connected(); // transport is live +await wh.from('clicks').insert({ page: '/home', button: 'signup' }); // this row will arrive on the stream +``` + ### `StreamOptions` | Field | Type | Description | @@ -96,9 +107,13 @@ interface StreamEvent { The SDK warns when more than 5 concurrent SSE connections are open (browser limit per domain). ::: +### Server-Side Policy Filtering + +Access-control policy applies on the server before anything reaches the client: tables the connection's role can't `select` are skipped, denied columns are stripped from each event, and the role's row-level `filter` is evaluated per subscriber against the connection's JWT claims. A stream on a row-policied table therefore delivers only the rows the policy admits for that connection — and, where the server's in-memory comparison can't prove a match, fewer; see [Access control — where each rule is enforced](/access-control#where-each-rule-is-enforced). + ### Client-Side Stream Filtering -When a `QueryBuilder` with `.where()` filters or `.select()` columns calls `.stream()`, the returned stream applies those filters client-side: +On top of that, when a `QueryBuilder` with `.where()` filters or `.select()` columns calls `.stream()`, the returned stream applies those filters client-side: ```ts const stream = wh.from('clicks') diff --git a/tests/e2e/sdk/streaming.test.ts b/tests/e2e/sdk/streaming.test.ts index 2a1c9314..edc9ab9d 100644 --- a/tests/e2e/sdk/streaming.test.ts +++ b/tests/e2e/sdk/streaming.test.ts @@ -165,14 +165,32 @@ describe("Streaming", () => { await inserter.from(T.clicks).insert({ ...base, event_id: usId, country: "US" }); await inserter.from(T.clicks).insert({ ...base, event_id: caId, country: "CA" }); - // Each subscriber receives its own country's row. Waiting for BOTH positives - // proves both rows were broadcast, so the cross-absence checks below are real - // (a row filtered out at the source can never arrive later). + // Each subscriber receives its own country's row. await waitForCondition(() => usEvents.some((e) => e.data?.event_id === usId), 10_000); await waitForCondition(() => caEvents.some((e) => e.data?.event_id === caId), 10_000); + // Barrier rows make the cross-absence checks deterministic: SSE frames on one + // connection are strictly ordered behind the same subject, so a leaked + // cross-country row published *before* a barrier must be parsed before the + // barrier row is — once each stream has seen its barrier, absence is proven, + // not just "not yet arrived". + const usBarrierId = testId(); + const caBarrierId = testId(); + await inserter.from(T.clicks).insert({ ...base, event_id: usBarrierId, country: "US" }); + await inserter.from(T.clicks).insert({ ...base, event_id: caBarrierId, country: "CA" }); + await waitForCondition( + () => usEvents.some((e) => e.data?.event_id === usBarrierId), + 10_000, + ); + await waitForCondition( + () => caEvents.some((e) => e.data?.event_id === caBarrierId), + 10_000, + ); + expect(usEvents.some((e) => e.data?.event_id === caId)).toBe(false); expect(caEvents.some((e) => e.data?.event_id === usId)).toBe(false); + expect(usEvents.some((e) => e.data?.event_id === caBarrierId)).toBe(false); + expect(caEvents.some((e) => e.data?.event_id === usBarrierId)).toBe(false); } finally { if (unsubUs) unsubUs(); if (unsubCa) unsubCa(); From 25d6751dd18037446c47f2dcfbdb3d0d013a8b0e Mon Sep 17 00:00:00 2001 From: taitelee Date: Tue, 11 Aug 2026 21:24:17 -0400 Subject: [PATCH 13/27] fix(stream): snapshot subscriber claims and reject trailing JSON after event decode --- internal/stream/hub.go | 13 ++++-- internal/stream/hub_test.go | 77 ++++++++++++++++++++++++++++++++++- internal/stream/metrics.go | 9 ++-- internal/stream/subscriber.go | 50 +++++++++++++++++++---- 4 files changed, 131 insertions(+), 18 deletions(-) diff --git a/internal/stream/hub.go b/internal/stream/hub.go index 11cebae2..31f3f7ab 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -3,6 +3,7 @@ package stream import ( "bytes" "encoding/json" + "io" "sync" "github.com/Wave-RF/WaveHouse/internal/discovery" @@ -219,9 +220,15 @@ func (h *Hub) columnKinds(table string) map[string]policy.ColumnKind { func decodeEvent(raw []byte, evt *ingest.EventMessage) bool { dec := json.NewDecoder(bytes.NewReader(raw)) dec.UseNumber() - // !More() rejects trailing content after the object, matching the strictness of - // the json.Unmarshal this replaces. - return dec.Decode(evt) == nil && !dec.More() && evt.TableName != "" + if dec.Decode(evt) != nil || evt.TableName == "" { + return false + } + // Token, not More: More() is an in-array/object cursor, not an end-of-input + // check — it reports false for a trailing "}" or "]" without consuming it. An + // io.EOF from Token proves nothing follows the object, matching the strictness + // of the json.Unmarshal this replaces. + _, err := dec.Token() + return err == io.EOF } // snapshotPolicy returns the current policy and whether filtering is configured. diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index 7f8b2310..772997d7 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -211,6 +211,38 @@ func TestHub_RowFilter_PerSubscriberIsolation(t *testing.T) { assertNoFrame(t, acme) } +// TestHub_RowFilter_ClaimsSnapshotImmuneToCallerMutation: NewSubscriber deep-copies +// the claims, so a caller that keeps the source map (the middleware-owned +// jwt.MapClaims outlives Hub.Add) can neither widen row visibility after +// registration nor race Broadcast's claims read. The mutation targets a NESTED +// value to prove the copy is deep, not a top-level shallow copy. +func TestHub_RowFilter_ClaimsSnapshotImmuneToCallerMutation(t *testing.T) { + t.Parallel() + p := &policy.Policy{ + Tables: map[string]policy.TablePolicy{ + "clicks": {Select: map[string]policy.RolePermissions{ + "viewer": {Filter: map[string]policy.Filter{"tenant_id": {Eq: new("{{ jwt.org.tenant }}")}}}, + }}, + }, + } + hub := NewHub(policy.NewMemoryStore(p), nil, nil) + const topic = "ingest.clicks" + + org := map[string]any{"tenant": "globex"} + claims := map[string]any{"org": org} + sub := NewSubscriber(claims) + hub.Add(topic, "viewer", sub) + + org["tenant"] = "acme" // the caller mutates its retained map after registration + + hub.Broadcast(topic, rawEvent(t, "clicks", "t", map[string]any{"tenant_id": "acme", "page": "/a"})) + assertNoFrame(t, sub) // visibility follows the snapshot ("globex"), not the mutation + + hub.Broadcast(topic, rawEvent(t, "clicks", "t", map[string]any{"tenant_id": "globex", "page": "/g"})) + assert.Equal(t, "/g", frameData(t, recvFrame(t, sub))["data"].(map[string]any)["page"], + "the snapshot keeps admitting the tenant the connection authenticated as") +} + // TestHub_RowFilter_MissingColumn_FailsClosed: an event that lacks the filtered // column can't be proven visible, so it is withheld rather than leaked. func TestHub_RowFilter_MissingColumn_FailsClosed(t *testing.T) { @@ -631,21 +663,64 @@ func BenchmarkBroadcast_RowFilteredFanout(b *testing.B) { for _, n := range []int{100, 1_000, 10_000} { b.Run(fmt.Sprintf("subscribers=%d", n), func(b *testing.B) { hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) + subs := make([]*Subscriber, n) for i := range n { tenant := "acme" if i%2 == 1 { tenant = "globex" } - hub.Add(topic, "viewer", NewSubscriber(map[string]any{"tenant": tenant})) + subs[i] = NewSubscriber(map[string]any{"tenant": tenant}) + hub.Add(topic, "viewer", subs[i]) } b.ReportAllocs() for b.Loop() { hub.Broadcast(topic, raw) + // Drain the delivered frames so every iteration measures successful + // row-filtered delivery: without this, queues fill after 64 events and + // later iterations measure the dropped-send path instead. The drain is + // one buffered-channel receive per visible subscriber — noise next to + // the per-subscriber policy evaluation under measurement. + for _, s := range subs { + for len(s.out) > 0 { + <-s.out + } + } } }) } } +// TestDecodeEvent_RequiresCleanEOF: Decoder.More is not an end-of-input check — +// it reports false for a trailing "}" or "]" without consuming it — so decodeEvent +// must read the decoder to io.EOF or a valid event followed by a stray delimiter +// would be accepted where json.Unmarshal (whose strictness this path preserves) +// rejects it. +func TestDecodeEvent_RequiresCleanEOF(t *testing.T) { + t.Parallel() + const valid = `{"table_name":"clicks","received_timestamp":"t","data":{"a":1}}` + tests := []struct { + name string + raw string + want bool + }{ + {"clean event", valid, true}, + {"trailing whitespace", valid + " \n", true}, + {"trailing close-brace", valid + "}", false}, + {"trailing close-bracket", valid + "]", false}, + {"trailing second value", valid + " 42", false}, + {"trailing garbage", valid + "garbage", false}, + {"missing table name", `{"received_timestamp":"t","data":{"a":1}}`, false}, + {"not json", "not json", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var evt ingest.EventMessage + assert.Equal(t, tt.want, decodeEvent([]byte(tt.raw), &evt)) + }) + } +} + func TestWireFrame(t *testing.T) { t.Parallel() tests := []struct { diff --git a/internal/stream/metrics.go b/internal/stream/metrics.go index 434fe297..c9cd9990 100644 --- a/internal/stream/metrics.go +++ b/internal/stream/metrics.go @@ -86,12 +86,9 @@ func (m *Metrics) FrameDropped(kind string) { } // RowWithheld records one event row withheld from one subscriber (live or replay) -// because the role's row-level-security filter did not admit it — including every -// fail-closed evaluation: a filter column absent from the event, a non-scalar or -// unparseable value, and a column with no usable schema. Labeled by table and role -// (bounded by the policy, not by data) so an operator can tell "no matching rows" -// from "a misconfigured filter is withholding everything for this role" — without -// it, a fail-closed security feature is indistinguishable from an idle stream. +// by the role's row-level-security filter, including fail-closed evaluations. +// Labeled by table and role (policy-bounded, not data-bounded) so an operator can +// tell "no matching rows" from "a misconfigured filter withholding everything". func (m *Metrics) RowWithheld(table, role string) { if m == nil { return diff --git a/internal/stream/subscriber.go b/internal/stream/subscriber.go index b260306b..3d4e673a 100644 --- a/internal/stream/subscriber.go +++ b/internal/stream/subscriber.go @@ -25,11 +25,13 @@ type Subscriber struct { // claims are this connection's JWT claims, evaluated against a role's row-level // security filter so the Hub can decide, per subscriber, whether each event row is - // visible (see Hub.Broadcast). Fixed at construction — claims are a property of the - // authenticated connection, and there is deliberately no setter, so the fan-out - // goroutine's unsynchronized read is race-free structurally (publication happens - // under the bucket mutex in Hub.Add). A policy reload needs no claims update: the - // Hub re-reads the policy store on every event, and claims only feed Evaluate. + // visible (see Hub.Broadcast). A private deep-copied snapshot, fixed at + // construction — claims are a property of the authenticated connection, and there + // is deliberately no setter — so the fan-out goroutine's unsynchronized read is + // race-free structurally: publication happens under the bucket mutex in Hub.Add, + // and no caller retains a reference that could mutate a nested claim (and with it + // an authorization decision) mid-stream. A policy reload needs no claims update: + // the Hub re-reads the policy store on every event, and claims only feed Evaluate. // nil ⇒ no claims (a tokenless subscriber), which fails any claim-scoped // row-filter closed. claims map[string]any @@ -45,14 +47,46 @@ type Subscriber struct { // NewSubscriber returns a Subscriber ready to register with a Heartbeater and the // event Hub, carrying the connection's JWT claims (nil for a tokenless caller). The // Hub evaluates the claims against a role's row-level-security filter to decide, -// per subscriber, whether each event row is visible; taking them here — with no -// setter — is what makes the claims immutable for the connection's lifetime. +// per subscriber, whether each event row is visible; the claims are deep-copied +// here — and there is no setter — so the subscriber owns an immutable snapshot: a +// caller that keeps the source map (e.g. the middleware-owned jwt.MapClaims) can +// neither change a visibility decision after registration nor race the fan-out's +// claims read. func NewSubscriber(claims map[string]any) *Subscriber { s := newSubscriber(defaultSubscriberQueue) - s.claims = claims + s.claims = cloneClaimsMap(claims) return s } +// cloneClaimsMap deep-copies a decoded-JSON claims tree (nested objects and +// arrays; scalars — string, bool, float64, json.Number, nil — are immutable and +// shared). nil in, nil out, preserving the tokenless-subscriber signal. +func cloneClaimsMap(m map[string]any) map[string]any { + if m == nil { + return nil + } + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = cloneClaimValue(v) + } + return out +} + +func cloneClaimValue(v any) any { + switch t := v.(type) { + case map[string]any: + return cloneClaimsMap(t) + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = cloneClaimValue(e) + } + return out + default: + return v + } +} + // newSubscriber builds a Subscriber with an explicit queue size — the seam tests // use to exercise the full-queue/drop path without enqueuing 64 frames. func newSubscriber(size int) *Subscriber { From d1560632b439f62343f2b2baaf3f63a8036361c6 Mon Sep 17 00:00:00 2001 From: taitelee Date: Tue, 11 Aug 2026 22:06:56 -0400 Subject: [PATCH 14/27] docs(stream): filter constants must be valid on both surfaces; ingest type matrix checks shape only --- docs/src/content/docs/access-control.mdx | 2 +- docs/src/content/docs/api.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index 9b284931..f89fad1a 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -379,7 +379,7 @@ SSE subscribers are checked for table-level `select` permission, have denied col - **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a 64-bit ID never falsely matches a neighbor, whether it arrives string-encoded or as a bare JSON number); an unparseable or `NaN` operand withholds the row. - **`String` columns** (again under any `Nullable`/`LowCardinality` wrapping): byte comparison *is* ClickHouse's String comparison — equality and ordering are both exact. -- **Every other type** (`Enum`, `UUID`, `Date`/`DateTime`, `Bool`, `IPv4`/`IPv6`, `FixedString`, …): only byte-equality is trusted. `_eq`/`_in` admit exactly the event's own text rendering — write the filter value the way your events carry it (`true`, not `1`; a lowercase UUID if that's what clients send). `_neq`, `_gt` and `_lt` withhold **every** row: a text difference can be pure representation (an uppercase UUID, an alternate date format, an Enum name vs. its number), so inequality and order are unprovable without ClickHouse — on these columns, use the query path for ordering/exclusion filters. +- **Every other type** (`Enum`, `UUID`, `Date`/`DateTime`, `Bool`, `IPv4`/`IPv6`, `FixedString`, …): only byte-equality is trusted. `_eq`/`_in` admit exactly the event's own text rendering — write the filter value the way your events carry it (`true`, not `1`; a lowercase UUID if that's what clients send). The same constant is bound into the query path's SQL, where ClickHouse compares it against the column's declared type rather than the event's text rendering — so pick a value that works on both surfaces, and verify the query path returns what you expect before relying on the filter. `_neq`, `_gt` and `_lt` withhold **every** row: a text difference can be pure representation (an uppercase UUID, an alternate date format, an Enum name vs. its number), so inequality and order are unprovable without ClickHouse — on these columns, use the query path for ordering/exclusion filters. - **No usable schema** — the table is unknown to schema discovery, or discovery is still failing at boot (the server serves while retrying in the background): every column is treated as the "other" bucket above. Equality scoping keeps working; ordering and `_neq` withhold until a schema is available. A few more edges worth knowing when you write a policy — the stream evaluates the **ingested event payload**, not the stored row, and each of these follows from that: diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index f5952430..d0eb3090 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -214,7 +214,7 @@ The body is a **flat JSON object** whose keys must match column names in the tar - Type mismatches are rejected (e.g., sending a JSON object for a `Float64` column). - Missing required columns (non-nullable without a default) are rejected. - Null values for non-nullable columns are rejected. -- Type compatibility: `Int*`/`Float*`/`Decimal*` accept JSON numbers **or numeric strings** — the string form exists so 64-bit IDs survive JavaScript's `Number` precision loss; `String`/`FixedString`/`UUID` accept strings, numbers, or booleans (coerced); `Date*`/`DateTime*`, `Enum8/16`, and `IPv4`/`IPv6` accept strings or numbers; `Bool` accepts booleans, numbers (`0`/`1`), or strings (`"true"`/`"false"`); `Array` accepts JSON arrays; `Map` accepts JSON objects; `Tuple` accepts arrays or objects. A ClickHouse type outside this matrix accepts any value and defers validation to ClickHouse. +- Type compatibility: `Int*`/`Float*`/`Decimal*` accept JSON numbers **or strings** — the string form exists so 64-bit IDs survive JavaScript's `Number` precision loss; `String`/`FixedString`/`UUID` accept strings, numbers, or booleans (coerced); `Date*`/`DateTime*`, `Enum8/16`, and `IPv4`/`IPv6` accept strings or numbers; `Bool` accepts booleans, numbers, or strings; `Array` accepts JSON arrays; `Map` accepts JSON objects; `Tuple` accepts arrays or objects. A ClickHouse type outside this matrix accepts any value and defers validation to ClickHouse. This matrix checks JSON *shape* only, never string contents — `"abc"` on an `Int64` column is accepted here and fails at the ClickHouse insert, where the row lands in the [DLQ](#dead-letter-queue-dlq). - `Nullable()` and `LowCardinality()` wrappers are handled transparently. **Response (accepted):** From e34753b9a52229d003223a1e545d365ee72c386a Mon Sep 17 00:00:00 2001 From: taitelee Date: Tue, 11 Aug 2026 22:21:18 -0400 Subject: [PATCH 15/27] docs(sdk): qualify stream row-filter guarantee; document connected() rejections in throw list --- clients/ts/README.md | 2 +- docs/src/content/docs/api.md | 2 +- docs/src/content/docs/sdk/reference.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/clients/ts/README.md b/clients/ts/README.md index 58560e30..79439f18 100644 --- a/clients/ts/README.md +++ b/clients/ts/README.md @@ -129,7 +129,7 @@ const { data } = await wh.from('clicks').select('page').limit(10); Async request methods return a `Result` object — destructure it as `{ data, error }` — and never throw for anything the server returns. `.stream()` and `.liveQuery()` return controllers instead, reporting failures through the subscriber's `error` callback. -The SDK does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR`, and despite that error's `retryable: true` the SDK never re-dials the stream itself), `.stream()` / `.liveQuery()` in a runtime with no `EventSource`, and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call. +The SDK does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR`, and despite that error's `retryable: true` the SDK never re-dials the stream itself), `.stream()` / `.liveQuery()` in a runtime with no `EventSource`, and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call. `StreamController.connected()` also rejects — when the stream is already closed, closes before connecting, or doesn't reach `live` within `timeoutMs` (default `5_000` ms) — so `await` it inside a `try`/`catch`. ```ts const { data, error } = await wh.from('clicks').fetch(); diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index d0eb3090..fa0612ed 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -539,7 +539,7 @@ data: {"table_name":"clicks","received_timestamp":"2026-03-24T12:00:01.456Z","da Each SSE connection is bound to a single `?table=`; to consume multiple tables, open one connection per table. -**Note:** When access control policies are active, streamed events are filtered per the caller's role — tables without select permission are skipped, denied columns are removed from each event, and the role's row-level `filter` is evaluated per subscriber against the caller's JWT claims — so a connection is never delivered a row the query path would hide for that role, with one documented exception (a numeric filter over a column type that rounds the payload on insert). The claims come from the connection's token (the `Authorization` header, or the `?token=` fallback above), and replayed gap-fill events are filtered the same way. See [Access control — row-level security](/access-control#row-level-security) and the enforcement caution there for the stream's fail-closed comparison boundary. +**Note:** When access control policies are active, streamed events are filtered per the caller's role — tables without select permission are skipped, denied columns are removed from each event, and the role's row-level `filter` is evaluated per subscriber against the caller's JWT claims — so, for a filter constant written to be valid on both read surfaces (see the enforcement caution), a connection is never delivered a row the query path would hide for that role, with one documented exception (a numeric filter over a column type that rounds the payload on insert). The claims come from the connection's token (the `Authorization` header, or the `?token=` fallback above), and replayed gap-fill events are filtered the same way. See [Access control — row-level security](/access-control#row-level-security) and the enforcement caution there for the stream's fail-closed comparison boundary. **CORS:** `/v1/stream` honors the `server.cors_allowed_origins` allowlist like every endpoint, so a browser `EventSource` from an allowed origin connects normally. `Last-Event-ID` is allow-listed in the CORS preflight so fetch-based clients can resume cross-origin. diff --git a/docs/src/content/docs/sdk/reference.md b/docs/src/content/docs/sdk/reference.md index 5b5a66a0..d57a92e9 100644 --- a/docs/src/content/docs/sdk/reference.md +++ b/docs/src/content/docs/sdk/reference.md @@ -25,7 +25,7 @@ if (error?.code === 'ABORTED') { ## Error Handling -The SDK **never throws** for anything the server returns — all API errors come back in `Result.error`. It does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR` to the subscriber's `error` callback — see [Serving under a path prefix](/sdk#serving-under-a-path-prefix)), `.stream()` / `.liveQuery()` in a runtime with no `EventSource` (see [Runtime support](/sdk#runtime-support)), and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call, and surfaces on a stream as `SSE_CONNECT_ERROR`. +The SDK **never throws** for anything the server returns — all API errors come back in `Result.error`. It does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR` to the subscriber's `error` callback — see [Serving under a path prefix](/sdk#serving-under-a-path-prefix)), `.stream()` / `.liveQuery()` in a runtime with no `EventSource` (see [Runtime support](/sdk#runtime-support)), and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call, and surfaces on a stream as `SSE_CONNECT_ERROR`. [`StreamController.connected()`](/sdk/streaming#connectedtimeoutms) also rejects — when the stream is already closed, closes before connecting, or doesn't reach `live` within `timeoutMs` (default `5_000` ms) — so `await` it inside a `try`/`catch`. | Status | Code | Retryable | Description | |--------|------|-----------|-------------| From 47d8592e67dd89fa5982f55bb76f0e445b13b1d4 Mon Sep 17 00:00:00 2001 From: taitelee Date: Tue, 11 Aug 2026 22:31:17 -0400 Subject: [PATCH 16/27] docs(sdk): list all three connected() rejection paths on the streaming page --- docs/src/content/docs/sdk/streaming.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/sdk/streaming.md b/docs/src/content/docs/sdk/streaming.md index 700bb512..902f7790 100644 --- a/docs/src/content/docs/sdk/streaming.md +++ b/docs/src/content/docs/sdk/streaming.md @@ -69,7 +69,7 @@ Current connection status: `'connecting' | 'live' | 'reconnecting' | 'closed'`. ### `.connected(timeoutMs?)` -Returns a promise that resolves once `.status` reaches `'live'` — the "safe to ingest now" barrier, so tests and scripts don't need sleep loops between opening a stream and inserting the rows they expect to see. Rejects if the stream is already `'closed'`, or after `timeoutMs` milliseconds (default `5_000`) if it never connects. Safe to call before `.subscribe()`. +Returns a promise that resolves once `.status` reaches `'live'` — the "safe to ingest now" barrier, so tests and scripts don't need sleep loops between opening a stream and inserting the rows they expect to see. Rejects if the stream is already `'closed'`, if it closes before connecting, or after `timeoutMs` milliseconds (default `5_000`) if it never reaches `'live'` — `await` it inside a `try`/`catch`. Safe to call before `.subscribe()`. ```ts const stream = wh.from('clicks').stream(); From 9984e987a3fec7f7c61220d8524065f8ee9b9784 Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 12 Aug 2026 09:00:14 -0400 Subject: [PATCH 17/27] docs: cover scalar _in claims, split ingest check vs select filter enforcement, dedup subscriber comment --- SECURITY.md | 2 +- docs/src/content/docs/access-control.mdx | 2 +- internal/stream/subscriber.go | 9 ++------- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 6096303c..66c10bda 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,7 +25,7 @@ We will acknowledge receipt within 48 hours and aim to provide an initial assess WaveHouse handles data and enforces strict isolation: - **JWT validation**: The JWT middleware always runs (there is no on/off switch). Signing supports either an HMAC shared secret or a remote JWKS endpoint (`auth.jwks_url`). Accepted signing algorithms are restricted to the configured verifier's family — HMAC accepts only `HS256/384/512`, JWKS only the asymmetric set (`RS*`/`ES*`/`PS*`/`EdDSA`) — and the token's `alg` header is validated before any key material is used, so `alg: none` and algorithm-confusion attacks (re-signing with `HS256` against a JWKS deployment's public key) are rejected. A request with no token, or an invalid/expired one, falls back to the policy `default_role`; elevated access requires a valid token, and a denied request that carried a bad token fails loud (`401`) rather than as a bare `403`. -- **Role-based access control**: Roles are extracted from a configurable JWT claim path. Non-admin roles have per-table, per-column, row-level policies enforced on ingest, query, and the live SSE stream (the stream's in-memory row-filter comparison has a documented fail-closed boundary — see the [access-control docs](https://wavehouse.dev/access-control#where-each-rule-is-enforced)); the admin role (`policy.admin_role`, `"admin"` by default, exact case-sensitive match) bypasses them. The configured non-JWT operator key (`auth.operator_key`) likewise bypasses per-role policy — a matching request is authorized as a full-access platform operator without a JWT; treat it as an admin secret. A request presenting a *non-matching* operator key is logged at `WARN` and counted by `wavehouse_auth_operator_key_failures_total`, so probing of that credential is observable and alertable. +- **Role-based access control**: Roles are extracted from a configurable JWT claim path. Non-admin roles have per-table, per-column policies enforced on ingest, query, and the live SSE stream; row-level rules split by path — insert `check` constraints are enforced (and auto-injected) on ingest, while select `filter` predicates apply to structured queries and the live SSE stream (the stream's in-memory row-filter comparison has a documented fail-closed boundary — see the [access-control docs](https://wavehouse.dev/access-control#where-each-rule-is-enforced)); the admin role (`policy.admin_role`, `"admin"` by default, exact case-sensitive match) bypasses them. The configured non-JWT operator key (`auth.operator_key`) likewise bypasses per-role policy — a matching request is authorized as a full-access platform operator without a JWT; treat it as an admin secret. A request presenting a *non-matching* operator key is logged at `WARN` and counted by `wavehouse_auth_operator_key_failures_total`, so probing of that credential is observable and alertable. - **Input validation**: JSON payloads are validated against ClickHouse schemas before processing. - **Query passthrough**: Raw SQL via `POST /v1/admin/query` is restricted to the admin role — the same `RequireAdmin` gate as the rest of `/v1/admin/*`. A request with no/invalid token resolves to the `default_role`, which in a production config is not the admin role (setting `default_role` equal to the admin role is a loudly-warned, dev-only escape hatch), so it cannot reach this endpoint — the one exception is a request presenting the configured `auth.operator_key`, which reaches the whole `/v1/admin/*` surface (including this endpoint) without a JWT and even under a deleted policy, so treat that key as an admin secret. Raw SQL has no per-statement scope check (a full SQL parser would be needed to authorize predicates), so the role gate is the entire authorization story. Non-admin callers use structured queries (`POST /v1/query?table={table}`, validated against schema with permission injection) or named pipes (`GET/POST /v1/pipes/{name}`); raw-SQL grants to non-admin roles via the policy engine are no longer supported (the `raw_sql` field on policies has been removed). - **Supply chain**: Third-party GitHub Actions are pinned to full commit SHAs (enforced by the repository's Actions settings — `sha_pinning_required`). `govulncheck` runs on every push/PR. Dependabot opens weekly grouped PRs for Go modules, GitHub Actions, and the npm packages — one grouped PR covering the docs site, TS SDK, and E2E tests via the root pnpm workspace. Released artifacts ship signed [Sigstore](https://www.sigstore.dev/) build-provenance attestations — verify the container image with `gh attestation verify oci://ghcr.io/wave-rf/wavehouse: --repo Wave-RF/WaveHouse`, a downloaded release-binary archive with `gh attestation verify --repo Wave-RF/WaveHouse`, and the `@wavehouse/sdk` package via its npm provenance badge or `npm audit signatures`. (Provenance covers the published binaries and image, not `go install`, which compiles from source.) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index f89fad1a..20094ca1 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -534,7 +534,7 @@ Per-role permissions (`tables.
.select.` and `.insert.`): | ----- | ---- | ---------- | ----------- | | `allow_columns` | string[] | select, insert | Allowlist of columns. Empty or `["*"]` = all columns (minus `deny_columns`). | | `deny_columns` | string[] | select, insert | Blocklist of columns. Always wins over `allow_columns`. | -| `filter` | map | select | Row-level predicates (`_eq`/`_neq`/`_gt`/`_lt`/`_in`), ANDed together — injected as a SQL `WHERE` on structured reads and evaluated per subscriber on the live stream (see [enforcement](#where-each-rule-is-enforced)). `_in` takes a single array-valued claim → `col IN (…)`. Values support `{{ jwt.path }}` templating. | +| `filter` | map | select | Row-level predicates (`_eq`/`_neq`/`_gt`/`_lt`/`_in`), ANDed together — injected as a SQL `WHERE` on structured reads and evaluated per subscriber on the live stream (see [enforcement](#where-each-rule-is-enforced)). `_in` takes a single claim → `col IN (…)`: an array-valued claim contributes each element, a scalar claim acts as a one-element set. Values support `{{ jwt.path }}` templating. | | `check` | map | insert | Required insert values (`_eq`, or `_in` for a claim-derived set; `_neq`/`_gt`/`_lt` are rejected). `_eq` is enforced if present and auto-injected if absent; `_in` requires the column be present and in-set. Supports templating. | | `allowed_aggregations` | string[] | select | Allowlist of aggregation functions. Empty = all (minus denied). Case-insensitive. | | `denied_aggregations` | string[] | select | Blocklist of aggregation functions. Always wins. | diff --git a/internal/stream/subscriber.go b/internal/stream/subscriber.go index 3d4e673a..8c67f693 100644 --- a/internal/stream/subscriber.go +++ b/internal/stream/subscriber.go @@ -45,13 +45,8 @@ type Subscriber struct { } // NewSubscriber returns a Subscriber ready to register with a Heartbeater and the -// event Hub, carrying the connection's JWT claims (nil for a tokenless caller). The -// Hub evaluates the claims against a role's row-level-security filter to decide, -// per subscriber, whether each event row is visible; the claims are deep-copied -// here — and there is no setter — so the subscriber owns an immutable snapshot: a -// caller that keeps the source map (e.g. the middleware-owned jwt.MapClaims) can -// neither change a visibility decision after registration nor race the fan-out's -// claims read. +// event Hub, carrying the connection's JWT claims (nil for a tokenless caller), +// deep-copied — see the claims field for the snapshot rationale. func NewSubscriber(claims map[string]any) *Subscriber { s := newSubscriber(defaultSubscriberQueue) s.claims = cloneClaimsMap(claims) From 1ea690b870365be7b750c188c556613a811970bd Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 12 Aug 2026 09:36:32 -0400 Subject: [PATCH 18/27] docs: scope narrowing fail-open to threshold ops, cover scalar _in and UInt*, split row-level enforcement by path --- docs/src/content/docs/access-control.mdx | 2 +- docs/src/content/docs/api.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index 20094ca1..fd2fabb4 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -386,7 +386,7 @@ A few more edges worth knowing when you write a policy — the stream evaluates - **A filtered column the payload doesn't carry withholds *every* event** for that subscriber, even though the same filter matches normally on the query path. That bites a `MATERIALIZED`/`ALIAS` column (never part of an ingest payload) or a `DEFAULT` column your clients omit. The recommended [`check` + `filter` pairing](#insert-checks) is unaffected: an `_eq` insert `check` auto-injects its claim value into any payload that omits the column *before* the event is published, so the streamed event carries it and the matching row filter evaluates normally. - **A non-scalar event value** (array/object/null) under a filtered column withholds the row. -- **Insert-time numeric narrowing bounds the fail-closed guarantee itself** — this one edge can fail *open*. The guarantee holds for the *payload* value, and the one place that differs from the stored row is a payload with more precision than the column's declared type (a `Decimal`'s scale, `Float32` width), which is rounded on insert — so a numeric filter (equality or ordering alike) on such a column compares the pre-rounding payload, and can deliver an event whose stored row lands on the other side of the comparison: an `_eq "1.1"` on a `Float32` column matches the payload `1.1` on the stream while the query path compares against the stored `1.10000002…`. Equality scoping on identity columns (integer IDs, `String` tenants) is not affected — those store exactly. +- **Insert-time numeric narrowing bounds the fail-closed guarantee itself** — this one edge can fail *open*. The guarantee holds for the *payload* value, and the one place that differs from the stored row is a payload with more precision than the column's declared type (a `Decimal`'s scale, `Float32` width), which is rounded on insert — so a numeric `_neq`/`_gt`/`_lt` filter on such a column compares the pre-rounding payload, and can deliver an event whose stored row lands on the other side of the comparison: a `_gt: "1.004"` on a `Decimal(10, 2)` column delivers a payload of `1.005` on the stream, while the query path compares the stored `1.00` and hides the row. `_eq`/`_in` cannot fail open here: ClickHouse narrows the bound filter constant to the column's declared type just as insert narrowed the payload, so an equality that matches on the stream matches the stored row too. Columns that store exactly (integer IDs, `String` tenants) are unaffected under every operator. Rows withheld by row-level security are counted in `wavehouse_sse_rows_withheld_total` (labeled by table and role) — check it before concluding a quiet stream simply has no matching rows. diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index fa0612ed..34e5045d 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -214,7 +214,7 @@ The body is a **flat JSON object** whose keys must match column names in the tar - Type mismatches are rejected (e.g., sending a JSON object for a `Float64` column). - Missing required columns (non-nullable without a default) are rejected. - Null values for non-nullable columns are rejected. -- Type compatibility: `Int*`/`Float*`/`Decimal*` accept JSON numbers **or strings** — the string form exists so 64-bit IDs survive JavaScript's `Number` precision loss; `String`/`FixedString`/`UUID` accept strings, numbers, or booleans (coerced); `Date*`/`DateTime*`, `Enum8/16`, and `IPv4`/`IPv6` accept strings or numbers; `Bool` accepts booleans, numbers, or strings; `Array` accepts JSON arrays; `Map` accepts JSON objects; `Tuple` accepts arrays or objects. A ClickHouse type outside this matrix accepts any value and defers validation to ClickHouse. This matrix checks JSON *shape* only, never string contents — `"abc"` on an `Int64` column is accepted here and fails at the ClickHouse insert, where the row lands in the [DLQ](#dead-letter-queue-dlq). +- Type compatibility: `Int*`/`UInt*`/`Float*`/`Decimal*` accept JSON numbers **or strings** — the string form exists so 64-bit IDs survive JavaScript's `Number` precision loss; `String`/`FixedString`/`UUID` accept strings, numbers, or booleans (coerced); `Date*`/`DateTime*`, `Enum8/16`, and `IPv4`/`IPv6` accept strings or numbers; `Bool` accepts booleans, numbers, or strings; `Array` accepts JSON arrays; `Map` accepts JSON objects; `Tuple` accepts arrays or objects. A ClickHouse type outside this matrix accepts any value and defers validation to ClickHouse. This matrix checks JSON *shape* only, never string contents — `"abc"` on an `Int64` column is accepted here and fails at the ClickHouse insert, where the row lands in the [DLQ](#dead-letter-queue-dlq). - `Nullable()` and `LowCardinality()` wrappers are handled transparently. **Response (accepted):** @@ -539,7 +539,7 @@ data: {"table_name":"clicks","received_timestamp":"2026-03-24T12:00:01.456Z","da Each SSE connection is bound to a single `?table=`; to consume multiple tables, open one connection per table. -**Note:** When access control policies are active, streamed events are filtered per the caller's role — tables without select permission are skipped, denied columns are removed from each event, and the role's row-level `filter` is evaluated per subscriber against the caller's JWT claims — so, for a filter constant written to be valid on both read surfaces (see the enforcement caution), a connection is never delivered a row the query path would hide for that role, with one documented exception (a numeric filter over a column type that rounds the payload on insert). The claims come from the connection's token (the `Authorization` header, or the `?token=` fallback above), and replayed gap-fill events are filtered the same way. See [Access control — row-level security](/access-control#row-level-security) and the enforcement caution there for the stream's fail-closed comparison boundary. +**Note:** When access control policies are active, streamed events are filtered per the caller's role — tables without select permission are skipped, denied columns are removed from each event, and the role's row-level `filter` is evaluated per subscriber against the caller's JWT claims — so, for a filter constant written to be valid on both read surfaces (see the enforcement caution), a connection is never delivered a row the query path would hide for that role, with one documented exception (a numeric `_neq`/`_gt`/`_lt` filter over a column type that rounds the payload on insert). The claims come from the connection's token (the `Authorization` header, or the `?token=` fallback above), and replayed gap-fill events are filtered the same way. See [Access control — row-level security](/access-control#row-level-security) and the enforcement caution there for the stream's fail-closed comparison boundary. **CORS:** `/v1/stream` honors the `server.cors_allowed_origins` allowlist like every endpoint, so a browser `EventSource` from an allowed origin connects normally. `Last-Event-ID` is allow-listed in the CORS preflight so fetch-based clients can resume cross-origin. From feadbc016afd6395068ca95ba449528c60b1db9b Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 12 Aug 2026 10:28:13 -0400 Subject: [PATCH 19/27] hub test schema registry fix --- internal/stream/hub_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index 772997d7..ac23ddc0 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -12,6 +12,7 @@ import ( "github.com/Wave-RF/WaveHouse/internal/discovery" "github.com/Wave-RF/WaveHouse/internal/ingest" "github.com/Wave-RF/WaveHouse/internal/policy" + "github.com/Wave-RF/WaveHouse/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -290,7 +291,7 @@ func TestHub_RowFilter_SharedProjectionAcrossSameClaims(t *testing.T) { // retries in the background while the server serves). func TestHub_RowFilter_NumericOrdering_SchemaInformed(t *testing.T) { t.Parallel() - reg := discovery.NewSchemaRegistryFromMap([]*discovery.TableSchema{ + reg := testutil.NewTestSchemaRegistry(t, []*discovery.TableSchema{ {Name: "clicks", Columns: []discovery.Column{ {Name: "amount", Type: "UInt64"}, {Name: "page", Type: "String"}, @@ -582,7 +583,7 @@ func TestHub_ConcurrentRowFilteredBroadcast_Race(t *testing.T) { // marshaling a Go float64 would already have destroyed the value this test is about. func TestHub_RowFilter_BigIntegerExact(t *testing.T) { t.Parallel() - reg := discovery.NewSchemaRegistryFromMap([]*discovery.TableSchema{ + reg := testutil.NewTestSchemaRegistry(t, []*discovery.TableSchema{ {Name: "clicks", Columns: []discovery.Column{ {Name: "tenant_id", Type: "UInt64"}, {Name: "page", Type: "String"}, From bbd3176166e2433b644cb0ad5ea4de9082431d3e Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 12 Aug 2026 13:27:18 -0400 Subject: [PATCH 20/27] fix(policy): digit-exact claim constants, instant timestamp row filters --- AGENTS.md | 2 +- CHANGELOG.md | 4 +- docs/src/content/docs/access-control.mdx | 9 +- docs/src/content/docs/api.md | 4 +- docs/src/content/docs/architecture.md | 6 +- internal/auth/auth.go | 9 +- internal/auth/auth_test.go | 20 ++++ internal/discovery/timestamp.go | 29 ++++++ internal/discovery/timestamp_test.go | 68 ++++++++++++++ internal/policy/policy.go | 111 +++++++++++++++++++---- internal/policy/policy_test.go | 74 +++++++++++++-- internal/policy/rowfilter.go | 106 +++++++++++++++++----- internal/policy/rowfilter_test.go | 89 +++++++++++++++--- internal/stream/hub.go | 49 +++++----- internal/stream/hub_test.go | 73 ++++++++++++++- 15 files changed, 560 insertions(+), 93 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 96126137..7ac90956 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ The invariant index — what must stay true. Full narrative and rationale live i 9. **Singleflight** — `TieredCache` coalesces concurrent misses (`x/sync/singleflight`) to prevent cache stampede. 10. **Active Sweeper** — purges NATS messages that are both ACKed (written to CH) and older than the gap window; SSE gap-fill uses `DeliverByStartTime`, no in-process ring buffer. 11. **Hasura-style access control: fail-closed (security)** — `policy.IsAdmin` (role == `admin_role`, **exact case-sensitive**, default `"admin"`) is the single admin check, shared by `Evaluate`/`ResolveRole`/`Validate`/the `/v1/admin` gate/`RoleAllowed`. Empty/absent role matches nothing (no `"*"` wildcard); `Validate` rejects empty role keys; a `nil` policy (deleted) denies **everyone incl. admin** via a role — a total lockout for token-based callers, so bootstrap from the policy file, never an implicit admin grant (**exception:** the operator key's `auth.IsOperator` bit passes the `/v1/admin` gate even under a `nil` policy — a deliberate break-glass restore over HTTP, see #7). `default_role` is the one sanctioned roleless exception (`ResolveRole` maps empty → it pre-eval); `default_role == admin_role` is permitted but dev-only and loudly warned (`policy.DefaultRoleGrantsAdmin`). Preserve when touching `internal/policy` (policy twin of #13; see #159). Detail: architecture.md § `policy/`. -12. **Structured queries: column authz fail-closed (security)** — `POST /v1/query?table={table}`: typed AST validated against schema, permission-enforced, timestamp-bucketed for cache, `DefaultMaxRows` (10,000) cap. Every column reference — projection, aggregation args, `filters`, `group_by`, `order_by`, `time_range` — is authorized inside `query.Build` (the single chokepoint that enumerates them all), so no clause can skip the role's `allow_columns`/`deny_columns` check (#223). A `select_all` read by a *column-restricted* role expands to its allowed columns via `policy.AllowedProjection`, never a bare `SELECT *`; *unrestricted*/admin roles keep `SELECT *` (`policy.RestrictsColumns` decides). Omitting `columns` selects nothing (`ErrEmptyProjection` → `200 []`); `["*"]` is the literal column `*` (schema-gated, not a wildcard); a table-granted role with no readable columns fails closed (`ErrNoReadableColumns` → `403`). Structured and live-stream (`stream.filterColumns`) reads share the one per-column decision `policy.IsColumnAllowed`, so column visibility can't drift. Row visibility has the same one-source guarantee (#319): `Evaluate` resolves a role's row-`filter` once (`resolvePredicates`), and both surfaces consume that single resolution — the query path renders it to SQL (`predicatesToSQL`), the stream evaluates it in memory per subscriber (`ResolvedPermissions.RowVisible`, whose type-aware comparison fails closed on anything it can't prove about the ingested payload — `policy.ColumnKind`; insert-time numeric narrowing is the one residual payload-vs-stored asymmetry, documented in the access-control enforcement caution) — so row visibility can't drift either. Preserve when touching `internal/query` or the structured-query handler. Detail: architecture.md § `query/`. +12. **Structured queries: column authz fail-closed (security)** — `POST /v1/query?table={table}`: typed AST validated against schema, permission-enforced, timestamp-bucketed for cache, `DefaultMaxRows` (10,000) cap. Every column reference — projection, aggregation args, `filters`, `group_by`, `order_by`, `time_range` — is authorized inside `query.Build` (the single chokepoint that enumerates them all), so no clause can skip the role's `allow_columns`/`deny_columns` check (#223). A `select_all` read by a *column-restricted* role expands to its allowed columns via `policy.AllowedProjection`, never a bare `SELECT *`; *unrestricted*/admin roles keep `SELECT *` (`policy.RestrictsColumns` decides). Omitting `columns` selects nothing (`ErrEmptyProjection` → `200 []`); `["*"]` is the literal column `*` (schema-gated, not a wildcard); a table-granted role with no readable columns fails closed (`ErrNoReadableColumns` → `403`). Structured and live-stream (`stream.filterColumns`) reads share the one per-column decision `policy.IsColumnAllowed`, so column visibility can't drift. Row visibility has the same one-source guarantee (#319): `Evaluate` resolves a role's row-`filter` once (`resolvePredicates`), and both surfaces consume that single resolution — the query path renders it to SQL (`predicatesToSQL`), the stream evaluates it in memory per subscriber (`ResolvedPermissions.RowVisible`, whose type-aware comparison fails closed on anything it can't prove about the ingested payload — `policy.ColumnSpec`, with `DateTime`/`DateTime64` operands compared as instants through the ingest grammar (`discovery.Column.TimeParser`) and claim constants rendered digit-exact (auth parses JWTs with `WithJSONNumber`; `policy.claimString` refuses a float64 past 2^53 rather than match a neighboring ID); insert-time numeric narrowing is the one residual payload-vs-stored asymmetry, documented in the access-control enforcement caution) — so row visibility can't drift either. Preserve when touching `internal/query` or the structured-query handler. Detail: architecture.md § `query/`. 13. **Named query pipes: fail-closed (security)** — pre-defined SQL templates (Tinybird-style) with param binding + caching; `GET/POST /v1/pipes/{name}` sit outside `RequireAdmin`, so per-pipe `allowed_roles` is the *only* execute-path gate, via `policy.RoleAllowed`: exact allowlist membership (no `"*"`), admin always passes, empty/absent role and empty-string entries authorize nobody, and no `allowed_roles` → admin-only. Preserve and exercise via `testutil.RunRoleMatrix` / `StandardRoleMatrix` (see #159). Detail: architecture.md § `pipes/`. 14. **TypeScript SDK** — `@wavehouse/sdk`: zero-dep client, typed query builder, real-time SSE, live queries (incrementable/decomposable/poll aggregation), codegen CLI. The canonical client (see §SDK Sync). 15. **Observability invariants** — stdout always 100% (sampling is OTLP-push-only); WARN+ERROR always export at 100% (a non-configurable floor — don't expose it); gRPC OTel exporters dial lazily so an unreachable collector never blocks startup; the OTel Prometheus exporter uses a **private** `prometheus.Registry`. The OTLP endpoint/TLS/custom-CA/mTLS/headers are delegated to the OpenTelemetry SDK's standard `OTEL_EXPORTER_OTLP_*` env vars — `InitProvider` passes **no** endpoint/header options. Known gap, intentionally not patched in WaveHouse app code: the pinned gRPC logs exporter (`otlploggrpc` v0.19/v0.20) ignores the env TLS-cert vars, so a custom/private CA and mutual TLS apply to traces/metrics but **not** the logs signal (public-CA/system-roots TLS and plaintext still work for logs) — upstream bug open-telemetry/opentelemetry-go#6661. A malformed `OTEL_EXPORTER_OTLP_HEADERS` is logged and skipped by the SDK (fail-soft), not fatal. Preserve when touching the logger/sampler/provider. Detail: architecture.md § `observability/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed20f6d..c82cce54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@wavehouse/sdk` `engines.node` floor back to `>=22`, matching the only line we test** (`clients/ts/package.json`, `clients/ts/README.md`, `docs/src/content/docs/sdk/index.mdx`, `docs/src/content/docs/sdk/queries.md`, `pnpm-workspace.yaml`): the floor was relaxed to `>=18` when the browser-first distribution landed (see the entry below), on the reasoning that the runtime needs only `fetch`. Nothing ever tested 18, though — `.nvmrc` pins 22 and `.github/actions/setup-env` consumes it via `node-version-file`, so 22 is the single version CI exercises — and Node 18 and 20 have both since reached upstream end-of-life. Declaring a floor we neither test nor is supported upstream promises more than it can back, so it returns to `>=22`. **Consumer impact:** installing on Node < 22 now warns with `EBADENGINE` under npm, and fails outright under pnpm with `engine-strict` enabled. The SDK README and the docs' Runtime support section state the requirement, which they previously either omitted or quoted as 18. -- **Live SSE events are now projected and serialized once per role instead of once per subscriber** (`internal/stream/hub.go` (new), `internal/stream/{subscriber,bucket,heartbeat,metrics,doc}.go`, `internal/api/stream.go`, `internal/api/hub.go` + `internal/api/transform.go` (both removed — the broadcast hub moves to `internal/stream`, and the orphaned test-only `transformForClient` is dropped), `cmd/wavehouse/main.go`, `docs/src/content/docs/architecture.md`, `AGENTS.md`, plus tests in `internal/stream/{hub,filter,subscriber,bucket,heartbeat}_test.go` and `internal/api/{stream,transform,router,errors}_test.go`): the first PR of the SSE delivery-path throughput epic ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)), building on the `internal/stream` primitives from #346. The broadcast hub moves into `internal/stream` as `Hub`: subscribers register under `(topic, role)`, and `Broadcast` decodes each event **once**, applies each subscribed role's column policy **once**, builds one SSE frame per role, and fans it to every member of that role's `Bucket`. Previously every connection independently ran `json.Unmarshal → policy.Evaluate → filterEventColumns → json.Marshal` (plus a second unmarshal just to read the `id:` timestamp) on the *same* event in its own read loop — byte-identical work repeated N times. For a single-role audience (the public dashboard, every viewer `public`) that collapses N re-projections to 1, moving the measured ~2 270 deliveries/s ceiling toward an events/s ceiling. The `(topic, role)` key is sufficient and claims-independent: column visibility derives only from the role+table policy entry, and the stream path applies no row-level filter (a documented invariant — if row-level filtering is ever added to streaming, the key must take claims into account). The handler's two `select` cases (keepalive vs. per-subscriber event) collapse into one byte-pump over a single `Subscriber.Frames()` queue carrying typed `Frame`s; the subscriber queue grows from cap 1 (keepalive-only) to 64 so live events buffer while the handler is mid-write. Gap-fill replay and `Last-Event-ID`/`?since=` resumption are unchanged (replay stays per-connection via the shared `stream.ReplayFrame`; live frames carry the same `id: `). Slow-consumer drops, silent before, now increment `wavehouse_sse_dropped_frames_total`; an inert `Subscriber.Evicted()` seam is wired for the eviction follow-up. The per-delivery OpenTelemetry span (another #294 item) was already removed in #346. **Deferred to follow-ups:** active slow-consumer eviction (#94) and right-sizing the subscriber buffer + broadcast lock cost (#152). +- **Live SSE events are now projected and serialized once per role instead of once per subscriber** (`internal/stream/hub.go` (new), `internal/stream/{subscriber,bucket,heartbeat,metrics,doc}.go`, `internal/api/stream.go`, `internal/api/hub.go` + `internal/api/transform.go` (both removed — the broadcast hub moves to `internal/stream`, and the orphaned test-only `transformForClient` is dropped), `cmd/wavehouse/main.go`, `docs/src/content/docs/architecture.md`, `AGENTS.md`, plus tests in `internal/stream/{hub,filter,subscriber,bucket,heartbeat}_test.go` and `internal/api/{stream,transform,router,errors}_test.go`): the first PR of the SSE delivery-path throughput epic ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)), building on the `internal/stream` primitives from #346. The broadcast hub moves into `internal/stream` as `Hub`: subscribers register under `(topic, role)`, and `Broadcast` decodes each event **once**, applies each subscribed role's column policy **once**, builds one SSE frame per role, and fans it to every member of that role's `Bucket`. Previously every connection independently ran `json.Unmarshal → policy.Evaluate → filterEventColumns → json.Marshal` (plus a second unmarshal just to read the `id:` timestamp) on the *same* event in its own read loop — byte-identical work repeated N times. For a single-role audience (the public dashboard, every viewer `public`) that collapses N re-projections to 1, moving the measured ~2 270 deliveries/s ceiling toward an events/s ceiling. The `(topic, role)` key is sufficient for the *column* projection because column visibility derives only from the role+table policy entry. (As first written, this entry also documented a "stream path applies no row-level filter" invariant, with a note that adding one would need a claims-aware key — **superseded** by the row-`filter` Security entry in this same block: the stream now applies a role's row filter per subscriber, and the key deliberately stays `(topic, role)`, each subscriber's claims gating delivery inside the bucket rather than fragmenting the projection key.) The handler's two `select` cases (keepalive vs. per-subscriber event) collapse into one byte-pump over a single `Subscriber.Frames()` queue carrying typed `Frame`s; the subscriber queue grows from cap 1 (keepalive-only) to 64 so live events buffer while the handler is mid-write. Gap-fill replay and `Last-Event-ID`/`?since=` resumption are unchanged (replay stays per-connection via the shared `stream.ReplayFrame`; live frames carry the same `id: `). Slow-consumer drops, silent before, now increment `wavehouse_sse_dropped_frames_total`; an inert `Subscriber.Evicted()` seam is wired for the eviction follow-up. The per-delivery OpenTelemetry span (another #294 item) was already removed in #346. **Deferred to follow-ups:** active slow-consumer eviction (#94) and right-sizing the subscriber buffer + broadcast lock cost (#152). - **CI is now a job DAG instead of one monolithic job, and the docs deploys no longer expose the Cloudflare token to PR-authored code** (`.github/workflows/ci.yml`, `.github/workflows/housekeeping.yml`, `.github/actions/setup-env/action.yml`, `Makefile`, `docs/wrangler.jsonc`, `AGENTS.md`, `docs/src/content/docs/development.md`, `docs/src/content/docs/claude-code.md`, `CONTRIBUTING.md`, `scripts/lint-pr-title.sh`, `.claude/hooks/agent-bash-gate.sh`): closes #305. The single `make ci` job becomes parallel jobs over the *same Makefile targets* (local `make ci` stays the dev mirror): `lint`, `unit`, `integration`, `e2e` (builds its own SDK dist + cover binary via `make -j test-e2e` on a warm per-suite cache and runs the suite exactly like a local run), `coverage` (a dedicated job that merges every suite's `coverage-` fragment and applies every threshold gate via `make cov` — like local `make ci`'s final step, so the gate is decoupled from the e2e suite; it's `needs: changes` only and *polls* for the fragments rather than `needs`-ing the suites, so its setup overlaps them and the merge fires ~10s after the last suite instead of serializing ~50s of setup onto the critical path), and `docs-build` (`make build-docs`, docs-affecting changes only, uploads the docs dist artifact the preview/deploy jobs consume) — public-repo runners are free and 4-core, so the pipeline spreads horizontally instead of queueing in one process. The architecture is documented once, in `.github/workflows/README.md` (DAG diagram, design invariants, cache key policy, add-a-job recipe, and the measured-but-deferred optimizations — e2e sharding among them), and the workflow's logic lives in shellcheck-gated scripts (`scripts/ci/` — `classify-changes.sh`, `check-pr-title.sh`, `docs-preview-comment.sh`, `timing-summary.sh`, `wait-artifact.sh`; over the shared, dependency-free path classifier `scripts/classify-paths.sh`, unit-tested by `scripts/classify-paths.test.sh` via `make test-classify-paths` and reused by the `pre-push` git hook so a docs/prose-only push requires only `make verify`, not a full `make ci` — the same suites CI skips for those changes) rather than inline YAML; caches are owned end-to-end by `setup-env` via nested `actions/cache` (automatic post-job saves — the per-job save-step boilerplate is gone); a non-gating `Timing summary` job writes a per-job wall-clock table to every run's Summary page; and `make verify` gains two leaves that gate the new surface area — `lint-sh` (shellcheck `v0.11.0`, checksum-verified install via `scripts/install-shellcheck.sh`) and `lint-gha` (actionlint `v1.7.12`) — so the CI plumbing is linted like any other source. The workflow also handles `merge_group` events (full suite against the merge-group ref), enabling a **merge queue** on `main`: the queue re-tests each PR against current main at landing time, which replaces the ruleset's "require branches to be up to date" rule — no more manual branch updates after every sibling merge. A new aggregator job named `CI` is the ruleset's **sole required status check** (it fails on any failed/cancelled job and counts skipped jobs as passing), so docs-only PRs skip the Go suites without orphaning the gate and future job changes never require ruleset edits. The PR-title (Conventional Commits) gate moves into the `PR title` job under that aggregator, validated by the same `scripts/lint-pr-title.sh` from a trusted `main` checkout; `PR housekeeping` (`pull_request_target`) drops to non-required and keeps what needs fork-PR write access — path labels, the sticky title-explainer comment, and a new nudge that re-runs the failed `PR title` job when a title edit fixes it (the job re-reads the title from the API, so no new push is needed). The **#305 fix**: docs previews/production deploys run in dedicated `docs-preview`/`docs-deploy` jobs that check out trusted `main` (wrangler, worker source, and config never resolve from the PR tree), consume only the static `docs/dist` artifact, and are the only jobs that reference `CLOUDFLARE_*` secrets; previews now publish right after `docs-build` instead of waiting on the full test pipeline, and the `docs-preview` deploy is **non-gating** — it's not in the `CI` aggregator's `needs` (only `docs-build` gates), so a slow or failed Cloudflare preview reports its own "Docs preview" check but never delays or reds the required check; production (`docs-deploy`, on the post-merge main push) still requires everything green. Per-job least-privilege permissions replace the old workflow-wide `contents: write`, and the Go build cache is partitioned per job (unit/integration/e2e compile with different flags) so each suite stays warm. @@ -37,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/stream/metrics.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/api.md`, `AGENTS.md`, `SECURITY.md`, `internal/stream/doc.go`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/discovery/validation_test.go`, `internal/stream/hub_test.go`, `tests/e2e/sdk/streaming.test.ts`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). The in-memory comparison is type-aware and **fails closed on anything it can't prove** (`policy.ColumnKind`, seeded from the schema registry): numeric columns (`Int*`/`UInt*`/`Float*`/`Decimal*`) compare numerically, matching ClickHouse — float64-equal ties resolve at arbitrary precision (`math/big`) and the hub decodes event payloads with `UseNumber` (exact digit strings, also keeping big integers byte-faithful on the SSE wire), so 64-bit IDs never falsely collide whether they arrive string-encoded (the JS-precision-loss escape hatch ingest accepts) or as bare JSON numbers, and an unparseable/`NaN` operand withholds the row — `String` columns compare bytewise (exactly ClickHouse's String semantics, equality *and* ordering), and every other type (`Enum`, `UUID`, `Date`/`DateTime`, `Bool`, IPs, `FixedString`, …) — plus every column when no schema is available (an unknown table, or the boot-time discovery-failure window where the server serves while retrying) — trusts byte-equality only: `_eq`/`_in` admit exactly the event's own text rendering, while `_neq`/`_gt`/`_lt` withhold the row, because a byte difference can be pure representation and admitting on it would deliver rows the query path excludes (an uppercase UUID under `_neq`; `"9" > "100"` as text under `_gt`). Ambiguity therefore always costs availability (a row withheld), never confidentiality — a guarantee about the ingested payload the stream evaluates, whose one payload-vs-stored asymmetry (insert-time numeric narrowing: `Decimal` scale, `Float32` width) is documented in the enforcement caution — and each withhold is observable via the new `wavehouse_sse_rows_withheld_total` counter (labeled by table and role, on the live and replay paths alike), separating "no matching rows" from "a fail-closed filter is withholding everything". Subscriber claims are fixed at construction (`stream.NewSubscriber(claims)` — no setter), so the fan-out's unsynchronized claims read is race-free structurally, with a dedicated `-race` test driving concurrent row-filtered broadcasts. Replay (gap-fill) applies the same per-connection row check via `Hub.ReplayProjector`, which caches the per-table column-kind lookup across the replay loop. **Perf note:** on a topic whose role carries a row-filter, part of the #294/#353 once-per-role fan-out gain is traded back for correctness — `policy.Evaluate` runs per subscriber per event there (the column projection stays shared; roles without a filter are unaffected); #435 tracks memoizing it, and `BenchmarkBroadcast_RowFilteredFanout` exists to measure it. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant noted in the #294/#353 Changed entry below. +- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/stream/metrics.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/api.md`, `AGENTS.md`, `SECURITY.md`, `internal/stream/doc.go`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/discovery/validation_test.go`, `internal/stream/hub_test.go`, `tests/e2e/sdk/streaming.test.ts`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). The in-memory comparison is type-aware and **fails closed on anything it can't prove** (`policy.ColumnKind`, seeded from the schema registry): numeric columns (`Int*`/`UInt*`/`Float*`/`Decimal*`) compare numerically, matching ClickHouse — float64-equal ties resolve at arbitrary precision (`math/big`) and the hub decodes event payloads with `UseNumber` (exact digit strings, also keeping big integers byte-faithful on the SSE wire), so 64-bit IDs never falsely collide whether they arrive string-encoded (the JS-precision-loss escape hatch ingest accepts) or as bare JSON numbers, and an unparseable/`NaN` operand withholds the row — `String` columns compare bytewise (exactly ClickHouse's String semantics, equality *and* ordering) — `DateTime`/`DateTime64` columns compare as **instants**, both operands read through the very grammar ingest canonicalization (#402) uses (`discovery.Column.TimeParser`: same spellings and zone rule, truncated to the column's precision, out-of-range instants that insert-time saturation would move refused), so a zone-less filter constant — the spelling query-path SQL wants — matches the canonical RFC 3339 payload the stream carries, time-window `_gt`/`_lt` policies work, and either operand the grammar can't read withholds the row — and every other type (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, IPs, `FixedString`, …) — plus every column when no schema is available (an unknown table, or the boot-time discovery-failure window where the server serves while retrying) — trusts byte-equality only: `_eq`/`_in` admit exactly the event's own text rendering, while `_neq`/`_gt`/`_lt` withhold the row, because a byte difference can be pure representation and admitting on it would deliver rows the query path excludes (an uppercase UUID under `_neq`; `"9" > "100"` as text under `_gt`). The filter *constants* are claims-derived, so the same exactness holds on that side too: the auth middleware now parses JWTs with `WithJSONNumber` (`internal/auth/auth.go` — a numeric claim keeps its exact digits as `json.Number` instead of collapsing to float64; before this, a Snowflake-scale `tenant` claim rendered as `"1e+16"` and the stream *delivered the float64-equal neighbor tenant's rows* while withholding the subscriber's own), and the policy engine's claim rendering (`claimString`) is hardened in depth: a float64 at or past 2^53 is refused outright — the predicate then matches **no** rows on either surface (`1 = 0` in SQL, unconditionally false in memory) — and smaller floats render positionally (`"1000000"`, never the `"1e+06"` spelling ClickHouse integer columns reject with a type error), which also fixes the check-clause auto-inject for such claims. Ambiguity therefore always costs availability (a row withheld), never confidentiality — a guarantee about the ingested payload the stream evaluates, whose one payload-vs-stored asymmetry (insert-time numeric narrowing: `Decimal` scale truncation, `Float32` rounding) is documented in the enforcement caution — and each withhold is observable via the new `wavehouse_sse_rows_withheld_total` counter (labeled by table and role, on the live and replay paths alike), separating "no matching rows" from "a fail-closed filter is withholding everything". Subscriber claims are fixed at construction (`stream.NewSubscriber(claims)` — no setter), so the fan-out's unsynchronized claims read is race-free structurally, with a dedicated `-race` test driving concurrent row-filtered broadcasts. Replay (gap-fill) applies the same per-connection row check via `Hub.ReplayProjector`, which caches the per-table column-kind lookup across the replay loop. **Perf note:** on a topic whose role carries a row-filter, part of the #294/#353 once-per-role fan-out gain is traded back for correctness — `policy.Evaluate` runs per subscriber per event there (the column projection stays shared; roles without a filter are unaffected); #435 tracks memoizing it, and `BenchmarkBroadcast_RowFilteredFanout` exists to measure it. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant originally noted in the #294/#353 Changed entry (under **Changed** in this same block, which now points back here). - **A `?token=` query credential is now stripped from the request URL whichever credential wins, not only when it is the one used** (`internal/auth/auth.go`, `internal/auth/auth_test.go`, `docs/src/content/docs/api.md`, `docs/src/content/docs/reverse-proxy.mdx`, `docs/src/content/docs/sdk/index.mdx`): raised by CodeRabbit on [#448](https://github.com/Wave-RF/WaveHouse/pull/448). `bearerToken` returned from the `Authorization: Bearer` branch *before* the strip, so a request presenting both credentials — which any caller can do — left the unused JWT sitting in `r.URL` for the rest of the request's life. The operator-key path had the same shape one frame up, returning before `bearerToken` ran at all, so the strip is now resolved ahead of that branch too. Not an active leak today (WaveHouse's own request logging only ever records `r.URL.Path`, and the OTel HTTP instrumentation records no query attribute), so this is defense in depth rather than a fix for an observed exposure — but it closed an inconsistency in an invariant the code already asserted on the query-only path, where any later handler or future logging change would have turned it into one. The strip now runs once, before either credential path returns; header precedence is unchanged, unrelated query parameters survive, and both cases are pinned by tests. The docs correspondingly drop the "the header path leaves the query parameter untouched" caveat that described the old behavior. - **Policy `_in` is now enforced on both the row-`filter` and insert-`check` paths, closing a fail-open row-security gap** (`internal/policy/policy.go`, `internal/api/ingest.go`, `docs/src/content/docs/access-control.mdx`, plus tests in `internal/policy/policy_test.go`, `internal/api/ingest_test.go`): closes #224. The `Filter` schema accepted `_in` but the engine never read it: on the row-`filter`/SELECT path `resolveFilters` had no `_in` branch, so a row-security filter like `tenant_id: { _in: … }` produced **no `WHERE` predicate** and the role saw every row instead of its tenant subset (a fail-open, same family as #223); on the `check`/INSERT path only `_eq` was honored, silently dropping any other operator. `_in` now takes a single claim that resolves to a JSON **array** (the multi-tenant case — a token's `tenant_ids` list) and emits `col IN (?, …)` with one bound param per element; a scalar claim is a one-element set, and an empty/absent claim matches **no rows** (fail-closed) rather than widening to all of them. On the insert path an `_in` check requires the column be present and one of the set — there is no single value to auto-inject as `_eq` does, so an omitted column is rejected (`403 check failed`). The comparison operators are enforced on `filter` (`_eq`/`_neq`/`_gt`/`_lt`/`_in` all produce predicates now, so nothing is rejected there) and, on `check`, `_neq`/`_gt`/`_lt` become a loud config-load rejection (no insert-time semantics; `check` honors `_eq` + `_in`). The `_in` value stays a single templated string in the wire schema (Go `Filter.In`, SDK `PolicyFilter._in`), matching the established "set = array" shape of the caller-query `in` operator. - **`denied_aggregations` is now enforced case-insensitively against the caller-supplied function name, closing a policy bypass** (`internal/policy/policy.go`, plus tests in `internal/policy/policy_test.go`): closes #318. `IsAggregationAllowed` lower-cased the aggregation name only *after* the deny-list loop and the empty-allow-list early return, so the deny check compared a lower-cased deny entry against the raw caller input — a denied aggregation slipped past simply by changing case (`SUM` bypassed a `sum` deny entry, and with an empty allow list the call was then permitted). `isValidAggFn` already accepts any casing and the SQL builder emits the function name verbatim, so the denied aggregation actually executed. The case fold now happens once, before the deny check, so deny wins regardless of caller casing — matching the case-insensitive contract the access-control docs already specified. diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index fd2fabb4..7248a3c5 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -379,14 +379,15 @@ SSE subscribers are checked for table-level `select` permission, have denied col - **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a 64-bit ID never falsely matches a neighbor, whether it arrives string-encoded or as a bare JSON number); an unparseable or `NaN` operand withholds the row. - **`String` columns** (again under any `Nullable`/`LowCardinality` wrapping): byte comparison *is* ClickHouse's String comparison — equality and ordering are both exact. -- **Every other type** (`Enum`, `UUID`, `Date`/`DateTime`, `Bool`, `IPv4`/`IPv6`, `FixedString`, …): only byte-equality is trusted. `_eq`/`_in` admit exactly the event's own text rendering — write the filter value the way your events carry it (`true`, not `1`; a lowercase UUID if that's what clients send). The same constant is bound into the query path's SQL, where ClickHouse compares it against the column's declared type rather than the event's text rendering — so pick a value that works on both surfaces, and verify the query path returns what you expect before relying on the filter. `_neq`, `_gt` and `_lt` withhold **every** row: a text difference can be pure representation (an uppercase UUID, an alternate date format, an Enum name vs. its number), so inequality and order are unprovable without ClickHouse — on these columns, use the query path for ordering/exclusion filters. -- **No usable schema** — the table is unknown to schema discovery, or discovery is still failing at boot (the server serves while retrying in the background): every column is treated as the "other" bucket above. Equality scoping keeps working; ordering and `_neq` withhold until a schema is available. +- **`DateTime`/`DateTime64` columns** (again under any wrapping): both operands are parsed as **instants** — through the same grammar [ingest canonicalization](/api#timestamp-canonicalization) reads — and compared chronologically, so all five operators are exact and the constant's spelling doesn't need to match the event's: ingest rewrites payload values to RFC 3339 UTC before publishing, and a zone-less constant (`2026-06-21 04:00:00`, read in the column's declared zone — ClickHouse's own rule) still matches the rewritten payload denoting that instant. **Write timestamp constants zone-less like that**: it's the one spelling that also works verbatim in the query path's SQL on every ClickHouse release (releases before 26.x reject the RFC 3339 `Z` form in a `WHERE` comparison with a type error; 26.x accepts it, and Unix-seconds strings, there too — the stream accepts every grammar spelling regardless). An operand the grammar can't read — on either side — withholds the row, as does an instant outside the column type's range (which insert-time *saturation* would have moved anyway). A timestamp column whose zone can't be resolved at runtime (see the canonicalization notes) still compares zone-explicit operands but refuses zone-less ones rather than guess the zone. +- **Every other type** (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, `IPv4`/`IPv6`, `FixedString`, …): only byte-equality is trusted. `_eq`/`_in` admit exactly the event's own text rendering — write the filter value the way your events carry it (`true`, not `1`; a lowercase UUID if that's what clients send; `Date` values keep the producer's spelling — unlike `DateTime`, they are not canonicalized at ingest). The same constant is bound into the query path's SQL, where ClickHouse compares it against the column's declared type rather than the event's text rendering — so pick a value that works on both surfaces, and verify the query path returns what you expect before relying on the filter. `_neq`, `_gt` and `_lt` withhold **every** row: a text difference can be pure representation (an uppercase UUID, an alternate date format, an Enum name vs. its number), so inequality and order are unprovable without ClickHouse — on these columns, use the query path for ordering/exclusion filters. +- **No usable schema** — the table is unknown to schema discovery, or discovery is still failing at boot (the server serves while retrying in the background): every column is treated as the "other" bucket above (timestamp columns lose their parser too). Equality scoping keeps working; ordering and `_neq` withhold until a schema is available. A few more edges worth knowing when you write a policy — the stream evaluates the **ingested event payload**, not the stored row, and each of these follows from that: -- **A filtered column the payload doesn't carry withholds *every* event** for that subscriber, even though the same filter matches normally on the query path. That bites a `MATERIALIZED`/`ALIAS` column (never part of an ingest payload) or a `DEFAULT` column your clients omit. The recommended [`check` + `filter` pairing](#insert-checks) is unaffected: an `_eq` insert `check` auto-injects its claim value into any payload that omits the column *before* the event is published, so the streamed event carries it and the matching row filter evaluates normally. +- **A filtered column the payload doesn't carry withholds *every* event** for that subscriber, even though the same filter matches normally on the query path. That bites a `MATERIALIZED`/`ALIAS` column (never part of an ingest payload) or a `DEFAULT` column your clients omit. The recommended [`check` + `filter` pairing](#insert-checks) is unaffected: an `_eq` insert `check` auto-injects its claim value into any payload that omits the column *before* the event is published, so the streamed event carries it and the matching row filter evaluates normally. (That holds for timestamp columns too: the injected claim value is canonicalized with the rest of the payload before publish, and the stream compares timestamps as instants, so the claim's spelling and the canonical wire spelling meet.) - **A non-scalar event value** (array/object/null) under a filtered column withholds the row. -- **Insert-time numeric narrowing bounds the fail-closed guarantee itself** — this one edge can fail *open*. The guarantee holds for the *payload* value, and the one place that differs from the stored row is a payload with more precision than the column's declared type (a `Decimal`'s scale, `Float32` width), which is rounded on insert — so a numeric `_neq`/`_gt`/`_lt` filter on such a column compares the pre-rounding payload, and can deliver an event whose stored row lands on the other side of the comparison: a `_gt: "1.004"` on a `Decimal(10, 2)` column delivers a payload of `1.005` on the stream, while the query path compares the stored `1.00` and hides the row. `_eq`/`_in` cannot fail open here: ClickHouse narrows the bound filter constant to the column's declared type just as insert narrowed the payload, so an equality that matches on the stream matches the stored row too. Columns that store exactly (integer IDs, `String` tenants) are unaffected under every operator. +- **Insert-time numeric narrowing bounds the fail-closed guarantee itself** — this one edge can fail *open*. The guarantee holds for the *payload* value, and the one place that differs from the stored row is a payload with more precision than the column's declared type, which the insert narrows — a `Decimal` **truncates** at its scale (`1.005`, `1.006` and `1.009` all store as `1.00` in a `Decimal(10, 2)`), while a `Float32` **rounds** to its nearest representable value (`16777217` stores as `16777216`) — so a numeric `_neq`/`_gt`/`_lt` filter on such a column compares the pre-narrowing payload, and can deliver an event whose stored row lands on the other side of the comparison: a `_gt: "1.004"` on a `Decimal(10, 2)` column delivers a payload of `1.005` on the stream, while the query path compares the stored (truncated) `1.00` and hides the row. `_eq`/`_in` cannot fail open here: ClickHouse narrows the bound filter constant to the column's declared type just as insert narrowed the payload, so an equality that matches on the stream matches the stored row too. Columns that store exactly (integer IDs, `String` tenants) are unaffected under every operator. Rows withheld by row-level security are counted in `wavehouse_sse_rows_withheld_total` (labeled by table and role) — check it before concluding a quiet stream simply has no matching rows. diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index 7c16816e..01ef9171 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -279,7 +279,7 @@ curl -X POST "http://localhost:8080/v1/ingest?table=clicks" \ WaveHouse pins `date_time_input_format=best_effort` on its inserts — the ClickHouse server default since 26.5. On an older server whose default was `basic`, a plain `DateTime` column read an all-digit timestamp string of five or more digits as Unix seconds (shorter runs it rejected outright, where `best_effort` reads `"2026"` as a year); under `best_effort`, `"20260711"` stores 2026-07-11, not 1970-08-23, and some lengths (e.g. 12 digits) are rejected outright. (`DateTime64` columns diverge the same way on calendar-shaped runs — `"20260711"` is 1970-08-23 under `basic`, 2026-07-11 under `best_effort` — and additionally whenever an epoch run's unit doesn't match the column scale, e.g. a 16-digit microsecond epoch into a `DateTime64(3)`; an epoch run whose unit matches the column scale (a 13-digit millisecond epoch into a `DateTime64(3)`) reads identically too — only 9–10-digit Unix-seconds runs, with an optional fraction, agree at *every* scale.) The canonical form itself is what the pin rescues: under `basic` an RFC 3339 value's `Z` suffix is rejected outright (the row fails and lands in the DLQ), and the pin is what makes it insertable regardless of server version. Zone-less date-times and 9–10-digit Unix-seconds strings parse identically under both settings. ::: -**The canonical form, precisely.** This is the one strict timestamp spelling in WaveHouse — the same one `/v1/query` and `/v1/pipes/{name}` render for top-level timestamp columns and the SSE stream carries (the raw-SQL proxy `/v1/admin/query` instead renders server-side via `date_time_output_format=iso`, which keeps trailing fraction zeros), and the form the stream row-filter will require for timestamp comparisons once row-level enforcement lands ([#381](https://github.com/Wave-RF/WaveHouse/issues/381)): +**The canonical form, precisely.** This is the one strict timestamp spelling in WaveHouse — the same one `/v1/query` and `/v1/pipes/{name}` render for top-level timestamp columns and the SSE stream carries (the raw-SQL proxy `/v1/admin/query` instead renders server-side via `date_time_output_format=iso`, which keeps trailing fraction zeros). The stream row-filter doesn't require it: row-level enforcement compares timestamp operands as **instants** under this same input grammar, so a filter constant in any accepted spelling — zone-less, RFC 3339, Unix seconds — matches the canonical payload denoting the same instant, and an operand the grammar can't read withholds the row (see [the enforcement caution](/access-control#where-each-rule-is-enforced) for per-type comparison rules and the spelling that also works in query-path SQL): - `YYYY-MM-DDTHH:MM:SSZ`, or `YYYY-MM-DDTHH:MM:SS.FZ` when there is a sub-second part: uppercase `T` separator, uppercase `Z` suffix, always UTC — never a numeric offset — and seconds always present. - The fraction is **truncated** (never rounded) to the column's precision: a `DateTime` column (whole seconds) never carries a fraction; a `DateTime64(3)` column carries at most three digits. @@ -577,7 +577,7 @@ Each SSE connection is bound to a single `?table=`; to consume multiple tables, Row values of top-level `DateTime`/`DateTime64` columns inside `data` arrive in the canonical RFC 3339 UTC form (ingest rewrites them before publishing — see [timestamp canonicalization](#timestamp-canonicalization)), so a live event and a `/v1/query` read of the same row agree on the instant in zone-explicit form — a zone-less spelling no longer parses as local time in a browser ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)). The two renderings are byte-identical regardless of the declared time zone or a `Nullable` wrapper — a column declared with a non-UTC zone also streams as `Z`, and `/v1/query` normalizes it (nullable or not) to UTC before rendering. Canonicalization is fail-open at ingest, so a value outside the accepted input forms streams in whatever spelling the producer sent — and for exactly those events the byte-identity above does not hold: a spelling ClickHouse accepts anyway is stored and still queries back canonical, while one it too rejects lands in the DLQ and never becomes queryable at all. Events ingested before this behavior shipped likewise replay in their original spelling. -**Note:** When access control policies are active, streamed events are filtered per the caller's role — tables without select permission are skipped, denied columns are removed from each event, and the role's row-level `filter` is evaluated per subscriber against the caller's JWT claims — so, for a filter constant written to be valid on both read surfaces (see the enforcement caution), a connection is never delivered a row the query path would hide for that role, with one documented exception (a numeric `_neq`/`_gt`/`_lt` filter over a column type that rounds the payload on insert). The claims come from the connection's token (the `Authorization` header, or the `?token=` fallback above), and replayed gap-fill events are filtered the same way. See [Access control — row-level security](/access-control#row-level-security) and the enforcement caution there for the stream's fail-closed comparison boundary. +**Note:** When access control policies are active, streamed events are filtered per the caller's role: tables without `select` permission are skipped, denied columns are removed from each event, and the role's [row-level `filter`](/access-control#row-level-security) is evaluated per subscriber against the caller's JWT claims — supplied by the connection's token (the `Authorization` header, or the `?token=` fallback above), with replayed gap-fill events filtered the same way. For a filter constant the query path's SQL also accepts ([the enforcement caution](/access-control#where-each-rule-is-enforced) gives per-type guidance), a connection is never delivered a row the query path would hide for that role — every comparison the stream can't prove fails closed and withholds the row instead. The one exception is a numeric `_neq`/`_gt`/`_lt` filter over a column type that narrows the payload on insert (a `Decimal`'s scale, `Float32` width); the caution documents that edge. **CORS:** `/v1/stream` honors the `server.cors_allowed_origins` allowlist like every endpoint, so a browser `EventSource` from an allowed origin connects normally. `Last-Event-ID` is allow-listed in the CORS preflight so fetch-based clients can resume cross-origin. diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index 39d43cc9..cc96d00a 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -85,7 +85,7 @@ The API layer uses [Chi](https://github.com/go-chi/chi) for routing with Request The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)) lives next to the keepalive primitives it shares. One abstraction per file. -- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the type-aware comparison seeded from the schema registry — `policy.ColumnKind`: numeric columns compare numerically, `String` bytewise, everything else admits byte-equality only and fails ordering/`!=` closed, so a missing schema can never downgrade the comparison to a leak). Each row withheld this way increments `wavehouse_sse_rows_withheld_total`. This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayProjector` shares the same projection and per-connection row check for the handler's gap-fill, caching the per-table column-kind lookup across the replay loop. +- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the type-aware comparison seeded from the schema registry — `policy.ColumnSpec`: numeric columns compare numerically, `String` bytewise, `DateTime`/`DateTime64` as instants through the same parser ingest canonicalization uses (`discovery.Column.TimeParser` — one grammar, so filter constants and canonicalized payloads can't disagree on the instant), everything else admits byte-equality only and fails ordering/`!=` closed, so a missing schema can never downgrade the comparison to a leak). Each row withheld this way increments `wavehouse_sse_rows_withheld_total`. This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayProjector` shares the same projection and per-connection row check for the handler's gap-fill, caching the per-table column-kind lookup across the replay loop. - **subscriber.go** — `Subscriber`, the per-connection handle. It carries the connection's JWT claims, fixed at construction (`NewSubscriber(claims)`, no setter) — the claims the `Hub` resolves a role's row-level `filter` against, and immutability is what makes the fan-out's unsynchronized claims read race-free structurally. It owns a single ready-to-write outbound queue of `Frame`s (each tagged with its `kind`, so the handler labels the write where it happens): producers — the keepalive wheel and the event `Hub` — fan frames in with `Send` (non-blocking; a full queue drops and the `Hub` counts it), and the handler drains `Frames()` to the client verbatim. The queue is sized for buffering live events (cap 64, up from the keepalive-only cap 1; #152 will make it a knob), and an `Evicted()` channel is the seam the slow-consumer follow-up closes to disconnect a wedged consumer. - **bucket.go** — `Bucket`, the reusable fan-out primitive: a concurrency-safe set of subscribers. `Push` delivers a shared `Frame` to each fire-and-forget (the keepalive wheel's ring); `Snapshot` exposes the members so the event `Hub` can fan out while inspecting each `Send` result (to count drops). The `Hub` holds one `Bucket` per `(topic, role)` so a projected frame is built once and sent to every member instead of re-projected per subscriber. - **heartbeat.go** — The keepalive wheel (`Heartbeater`). A single process-wide ticker fans a minimal `:` comment across the ring of `Bucket`s, waking ~1/N of live streams per tick so the writes don't synchronize. The effective per-connection keepalive period is `stream.keepalive_interval` (the wheel ticks every `keepalive_interval ÷ keepalive_buckets`, so one rotation spans the interval); the owning handler goroutine does the actual write, so the shared ticker never touches a `ResponseWriter` directly. @@ -115,7 +115,7 @@ The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https:/ - **discovery.go** — `SchemaRegistry` queries `system.columns` to discover ClickHouse table schemas. Each refresh also discovers the server's default time zone (`SELECT timezone()`) and bakes every `DateTime`/`DateTime64` column's canonicalization spec (precision + resolved zone) into the cached schema, so the per-record ingest path parses no type strings and loads no zones ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)). Supports periodic auto-refresh, on-demand refresh, and `RetryRefresh` (boot-time exponential backoff loop used by `cmd/wavehouse` so a transiently unreachable ClickHouse doesn't crash-loop the binary). Thread-safe via `sync.RWMutex`. - **timestamp.go** — `CanonicalizeTimestamps(schema, data)` rewrites every parseable value in a top-level `DateTime`/`DateTime64` column to the canonical RFC 3339 UTC wire form before the event is published ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)): zone-less values are interpreted in the column's declared zone, else the discovered server default — ClickHouse's own rule, so the spelling changes but never the instant. Fail-open: an unparseable value or unresolvable zone passes through verbatim for ClickHouse's own parser to judge; ingest never rejects a record over its timestamp spelling. -- **validation.go** — `Validate(schema, data)` checks incoming JSON against the discovered schema: unknown fields, type compatibility, missing required columns, null handling. Also exports the type classifiers `IsNumericType` / `IsStringType` (unwrapping `Nullable`/`LowCardinality`), which seed the stream row-filter's `policy.ColumnKind` comparison. +- **validation.go** — `Validate(schema, data)` checks incoming JSON against the discovered schema: unknown fields, type compatibility, missing required columns, null handling. Also exports the type classifiers `IsNumericType` / `IsStringType` (unwrapping `Nullable`/`LowCardinality`), which — together with `Column.TimeParser` from timestamp.go — seed the stream row-filter's `policy.ColumnSpec` comparison. - **discovery_test.go** — Unit tests for validation logic. ### `ingest/` — Ingest Pipeline, DLQ & Sweeping @@ -141,7 +141,7 @@ The package's design invariants — stdout always 100%, WARN+ERROR always export ### `policy/` — Access Control - **policy.go** — Hasura-style policy types (`Policy`, `TablePolicy`, `RolePermissions`, `Filter`), `Evaluate()` function that resolves permissions against JWT claims (including `{{ jwt.claim.path }}` template resolution), the per-column decision `IsColumnAllowed()` plus its batch/projection forms `AllowedProjection()` and `RestrictsColumns()` (used to expand a `select_all` request into a role's allowed columns), `IsAggregationAllowed()`, `Validate()`. `Evaluate` resolves a role's row-`filter` **once** (`resolvePredicates`) and feeds both read surfaces from that single resolution — `predicatesToSQL` renders the query path's `WHERE`, and the same predicates ride on `ResolvedPermissions` for the stream's in-memory check — so row visibility can't drift between them (#319). -- **rowfilter.go** — the in-memory row-visibility twin of the SQL `WHERE`: `HasRowFilter`, `RowVisible` (evaluates the resolved predicates against a decoded event, per subscriber), and `ColumnKind` (`Numeric`/`Text`/`Opaque`) — the type-aware comparison classification whose zero value is the fail-closed floor: numeric columns compare numerically with full-precision tie-breaks, `String` bytewise, and everything else (including any column with no usable schema) admits byte-equality only, failing `!=`/`>`/`<` closed. +- **rowfilter.go** — the in-memory row-visibility twin of the SQL `WHERE`: `HasRowFilter`, `RowVisible` (evaluates the resolved predicates against a decoded event, per subscriber), and `ColumnSpec` — the per-column comparison contract (`ColumnKind` `Numeric`/`Text`/`Time`/`Opaque`, plus the caller-supplied instant parser for `Time`) whose zero value is the fail-closed floor: numeric columns compare numerically with full-precision tie-breaks, `String` bytewise, `DateTime`/`DateTime64` chronologically (both operands through the ingest grammar; either side unreadable ⇒ withheld), and everything else (including any column with no usable schema) admits byte-equality only, failing `!=`/`>`/`<` closed. - **store.go** — `Store` backed by NATS KV bucket `WAVEHOUSE_POLICY`. Supports file-based bootstrap (YAML/JSON), cluster-wide sync via KV Watch, local caching. ### `pipes/` — Named Query Pipes diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 31f781c9..2e703c0d 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -203,7 +203,14 @@ func Middleware(cfg Config, store *policy.Store, logger *slog.Logger) (func(http // default at the end (roleless + recorded error). Ordering it this way // means a future missing return, or a mis-set !ok/!Valid condition, // can never accidentally promote an unverified token to a real role. - token, err := jwt.Parse(tokenStr, keyFunc, jwt.WithValidMethods(validMethods)) + // WithJSONNumber decodes numeric claims as json.Number — exact digit + // strings — instead of float64. Claim values become row-filter and + // check constants in internal/policy, and a float64 has already + // collapsed every integer past 2^53 onto its neighbors: the difference + // between scoping a stream to one tenant and delivering the + // float64-equal tenant's rows (see policy.claimString, which refuses + // such floats as defense-in-depth). + token, err := jwt.Parse(tokenStr, keyFunc, jwt.WithValidMethods(validMethods), jwt.WithJSONNumber()) if err == nil && token.Valid { if claims, ok := token.Claims.(jwt.MapClaims); ok { ctx := WithClaims(r.Context(), claims) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index bc743264..4ae0d5e8 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "encoding/json" "errors" "log/slog" "net/http" @@ -108,6 +109,25 @@ func TestMiddleware_ValidToken_FlatRole(t *testing.T) { assert.NoError(t, c.authErr) } +// TestMiddleware_NumericClaims_ExactDigits: numeric claims reach handlers as +// json.Number — exact digit strings — never float64 (WithJSONNumber on the +// parser). Claims become the policy row-filter and check constants, and a +// float64 has collapsed every integer past 2^53 onto its neighbors — enough for +// a stream row-filter to match another tenant's rows (#381). exp/iat validation +// must keep working under the same decoding (jwt v5 reads json.Number dates). +func TestMiddleware_NumericClaims_ExactDigits(t *testing.T) { + t.Parallel() + c := run(t, cfg(), bearer(testutil.MakeJWT(t, map[string]any{ + "role": "editor", + "tenant": json.Number("10000000000000001"), + }))) + assert.Equal(t, "editor", c.role) + assert.NoError(t, c.authErr) + require.True(t, c.hasClaims) + assert.Equal(t, json.Number("10000000000000001"), c.claims["tenant"], + "bare JSON integer claims keep their exact digits — as float64 this reads 1e+16") +} + func TestMiddleware_BearerScheme_CaseInsensitive(t *testing.T) { t.Parallel() // RFC 7235 auth-schemes are case-insensitive; a lowercase / mixed-case diff --git a/internal/discovery/timestamp.go b/internal/discovery/timestamp.go index 24d93293..072096c4 100644 --- a/internal/discovery/timestamp.go +++ b/internal/discovery/timestamp.go @@ -51,6 +51,35 @@ func CanonicalizeTimestamps(schema *TableSchema, data map[string]any) { } } +// TimeParser returns the mapping from one rendering of this DateTime/DateTime64 +// column's value to the instant ClickHouse would store: the same grammar and +// zone rule ingest canonicalization applies (parseTimestamp), the same range +// guard (rewritable — an out-of-range operand, which insert-time saturation +// would move, is refused), truncated to the column's precision exactly like the +// canonical wire form. nil when the column isn't a timestamp column with a +// resolved spec; such columns keep byte-equality semantics on the stream. The +// stream row-filter uses it (policy.ColumnSpec.ParseTime) so a filter constant +// in any accepted spelling — zone-less read in the column's zone, RFC 3339, +// Unix seconds — and the canonicalized payload compare as instants (#381), +// through one grammar that can't drift from ingest's. +func (c *Column) TimeParser() func(v any) (time.Time, bool) { + spec := c.tsSpec + if spec == nil { + return nil + } + unit := time.Second + for range spec.precision { + unit /= 10 + } + return func(v any) (time.Time, bool) { + t, err := parseTimestamp(v, spec) + if err != nil || !spec.rewritable(t) { + return time.Time{}, false + } + return t.Truncate(unit), true + } +} + // timestampSpec is a timestamp column's precomputed canonicalization inputs: // sub-second precision (0 for DateTime), the column kind (ClickHouse reads // numbers and Unix-string fractions differently for DateTime64), and the zone diff --git a/internal/discovery/timestamp_test.go b/internal/discovery/timestamp_test.go index 9457c70c..d03a87f5 100644 --- a/internal/discovery/timestamp_test.go +++ b/internal/discovery/timestamp_test.go @@ -102,6 +102,74 @@ func TestCanonicalizeTimestamps(t *testing.T) { } } +// TestColumnTimeParser: the stream row-filter's per-column parser (#381) is the +// canonicalization grammar exactly — same spellings, zone rule, and Unix forms — +// truncated to the column's precision and bounded by the rewrite range, so a +// filter constant and a canonicalized payload always meet on the instant +// ClickHouse stores. +func TestColumnTimeParser(t *testing.T) { + t.Parallel() + utc4 := time.Date(2026, 6, 21, 4, 0, 0, 0, time.UTC) + + tests := []struct { + name string + colType string + value any + want time.Time + ok bool + }{ + {"canonical RFC 3339", "DateTime('UTC')", "2026-06-21T04:00:00Z", utc4, true}, + {"zone-less read in column zone", "DateTime('UTC')", "2026-06-21 04:00:00", utc4, true}, + {"explicit offset, same instant", "DateTime('UTC')", "2026-06-21T06:00:00+02:00", utc4, true}, + {"unix seconds string", "DateTime('UTC')", "1782014400", utc4, true}, + {"unix seconds number", "DateTime('UTC')", json.Number("1782014400"), utc4, true}, + {"fraction truncated to column precision", "DateTime64(1, 'UTC')", "2026-06-21T04:00:00.19Z", utc4.Add(100 * time.Millisecond), true}, + {"junk refused", "DateTime('UTC')", "not a timestamp", time.Time{}, false}, + {"out of range refused (insert-time saturation would move it)", "DateTime('UTC')", "2400-01-01T00:00:00Z", time.Time{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + schema := tsSchema(tt.colType) + resolveTimestampSpecs(schema, nil, discardLogger()) + parse := schema.Columns[0].TimeParser() + require.NotNil(t, parse) + got, ok := parse(tt.value) + assert.Equal(t, tt.ok, ok) + if tt.ok { + assert.True(t, got.Equal(tt.want), "got %v, want %v", got, tt.want) + } + }) + } +} + +// TestColumnTimeParser_NilOrZoneLimited: only DateTime/DateTime64 columns with a +// resolved spec carry a parser — String/Date columns and hand-built literals +// return nil (byte-equality semantics on the stream). A timestamp column whose +// zone is unknown still parses zone-explicit forms but refuses zone-less strings: +// the zone would be a guess, and a guessed instant could move a row across a +// filter boundary. +func TestColumnTimeParser_NilOrZoneLimited(t *testing.T) { + t.Parallel() + schema := &TableSchema{Name: "t", Columns: []Column{ + {Name: "s", Type: "String"}, + {Name: "d", Type: "Date"}, + {Name: "ts", Type: "DateTime"}, + }} + resolveTimestampSpecs(schema, nil, discardLogger()) + assert.Nil(t, schema.Columns[0].TimeParser(), "String column: no parser") + assert.Nil(t, schema.Columns[1].TimeParser(), "Date column: excluded from timestamp handling") + assert.Nil(t, tsSchema("DateTime").Columns[0].TimeParser(), "hand-built literal without spec resolution: no parser") + + unknownZone := schema.Columns[2].TimeParser() + require.NotNil(t, unknownZone, "zone-less DateTime with unknown server zone still has a (zone-explicit-only) parser") + _, ok := unknownZone("2026-06-21 04:00:00") + assert.False(t, ok, "zone-less string with unknown column zone: refused, never guessed") + got, ok := unknownZone("2026-06-21T04:00:00Z") + assert.True(t, ok) + assert.True(t, got.Equal(time.Date(2026, 6, 21, 4, 0, 0, 0, time.UTC))) +} + // TestCanonicalizeTimestamps_NoPrecomputedSpec: a schema that skipped spec // resolution (hand-built literals) passes through untouched. func TestCanonicalizeTimestamps_NoPrecomputedSpec(t *testing.T) { diff --git a/internal/policy/policy.go b/internal/policy/policy.go index c7be77ff..c6303178 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -1,8 +1,11 @@ package policy import ( + "encoding/json" "fmt" + "math" "regexp" + "strconv" "strings" "github.com/Wave-RF/WaveHouse/internal/chsql" @@ -213,7 +216,11 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * for col, f := range perms.Check { switch { case f.Eq != nil: - resolved.CheckClauses[col] = resolveTemplate(*f.Eq, claims) + // ok is deliberately ignored: an unrenderable claim (claimString) + // leaves its "" hole exactly like an absent claim, and a "" required + // value fails every real inserted value closed at the ingest compare. + v, _ := resolveTemplate(*f.Eq, claims) + resolved.CheckClauses[col] = v case f.In != nil: // A []any value marks a set-membership check (vs a scalar required // value); ingest enforces "inserted value must be one of these". @@ -229,7 +236,9 @@ func Evaluate(p *Policy, role, table, operation string, claims map[string]any) * // resolved to concrete string values — the shared, render-agnostic form the query // path turns into SQL (predicatesToSQL) and the stream path evaluates in memory // (RowVisible). Op is one of "=", "!=", ">", "<", "in". Values holds one element -// for the scalar operators and zero-or-more for "in" (empty ⇒ matches no rows). +// for the scalar operators and zero-or-more for "in"; an EMPTY Values matches no +// rows on either surface — an empty/unresolvable "in" set, or a scalar whose +// constant was unrenderable (see claimString). type resolvedPredicate struct { Column string Op string @@ -241,18 +250,29 @@ type resolvedPredicate struct { // operator order within a column (=, !=, >, <, in) mirrors the former inline SQL. func resolvePredicates(filters map[string]Filter, claims map[string]any) []resolvedPredicate { var preds []resolvedPredicate + // An unrenderable constant (ok=false from resolveTemplate: a claim whose exact + // value was lost upstream) yields a predicate with NO values, which matches no + // rows on either surface — never a synthesized stand-in that could match some + // other principal's rows. + scalar := func(col, op, tmpl string) resolvedPredicate { + v, ok := resolveTemplate(tmpl, claims) + if !ok { + return resolvedPredicate{Column: col, Op: op} + } + return resolvedPredicate{Column: col, Op: op, Values: []string{v}} + } for col, f := range filters { if f.Eq != nil { - preds = append(preds, resolvedPredicate{col, "=", []string{resolveTemplate(*f.Eq, claims)}}) + preds = append(preds, scalar(col, "=", *f.Eq)) } if f.Neq != nil { - preds = append(preds, resolvedPredicate{col, "!=", []string{resolveTemplate(*f.Neq, claims)}}) + preds = append(preds, scalar(col, "!=", *f.Neq)) } if f.Gt != nil { - preds = append(preds, resolvedPredicate{col, ">", []string{resolveTemplate(*f.Gt, claims)}}) + preds = append(preds, scalar(col, ">", *f.Gt)) } if f.Lt != nil { - preds = append(preds, resolvedPredicate{col, "<", []string{resolveTemplate(*f.Lt, claims)}}) + preds = append(preds, scalar(col, "<", *f.Lt)) } if f.In != nil { preds = append(preds, resolvedPredicate{col, "in", toStrings(resolveInValues(*f.In, claims))}) @@ -285,6 +305,12 @@ func predicatesToSQL(preds []resolvedPredicate) ([]string, []any) { } } default: + if len(p.Values) == 0 { + // Unrenderable scalar constant (see resolvePredicates): match no + // rows, the same fail-closed verdict as the empty-in case above. + clauses = append(clauses, "1 = 0") + continue + } clauses = append(clauses, fmt.Sprintf("%s %s ?", qcol, p.Op)) params = append(params, p.Values[0]) } @@ -312,10 +338,13 @@ func toStrings(vals []any) []string { } // resolveTemplate resolves {{ jwt.claim.path }} templates against JWT claims. -// If a claim path cannot be resolved, the template placeholder is replaced with -// an empty string to prevent "" from leaking into SQL filters. -func resolveTemplate(tmpl string, claims map[string]any) string { - return claimTemplateRe.ReplaceAllStringFunc(tmpl, func(match string) string { +// An ABSENT claim path is replaced with an empty string (preventing "" from +// leaking into SQL filters — the long-standing fallback); ok=false instead marks +// a claim that was present but unrenderable (see claimString), so the caller can +// refuse the whole constant rather than compare against a mis-rendered stand-in. +func resolveTemplate(tmpl string, claims map[string]any) (string, bool) { + ok := true + out := claimTemplateRe.ReplaceAllStringFunc(tmpl, func(match string) string { sub := claimTemplateRe.FindStringSubmatch(match) if len(sub) < 2 { return "" @@ -325,8 +354,44 @@ func resolveTemplate(tmpl string, claims map[string]any) string { if val == nil { return "" } - return fmt.Sprint(val) + s, sok := claimString(val) + if !sok { + ok = false + return "" + } + return s }) + return out, ok +} + +// claimString renders one claim value as the string constant a filter or check +// carries. ok=false marks a value whose exact form is unrecoverable: a float64 at +// or past 2^53 came through a decoder that had already collapsed neighboring JSON +// integers onto one float (jwt.Parse without WithJSONNumber — which the auth +// middleware no longer does), so ANY digits rendered for it could be another +// principal's ID; the caller must fail its predicate closed instead. Smaller +// floats render positionally — fmt.Sprint's exponent form ("1e+06" for a round +// million) is a spelling neither ClickHouse integer columns nor producers use. +// json.Number, what jwt.Parse yields for numbers, passes through with its exact +// digits. +func claimString(v any) (string, bool) { + switch x := v.(type) { + case string: + return x, true + case json.Number: + return string(x), true + case bool: + return strconv.FormatBool(x), true + case float64: + if math.Abs(x) >= 1<<53 { + return "", false + } + return strconv.FormatFloat(x, 'f', -1, 64), true + default: + // Non-scalar claim in a scalar position: keep the historical fmt.Sprint + // rendering ("map[…]", "[a b]"), which matches no real value. + return fmt.Sprint(x), true + } } // resolveInValues resolves a templated _in value into the set of bound values @@ -335,8 +400,10 @@ func resolveTemplate(tmpl string, claims map[string]any) string { // the multi-tenant case, where a token's tenant_ids list scopes the predicate. // A scalar claim (or any template with surrounding text) yields a single value, // matching resolveTemplate. Elements are stringified like the other operators so -// policy filters stay uniformly string-valued. Returns nil when the claim is -// absent so the caller can fail the predicate closed. +// policy filters stay uniformly string-valued. Returns nil — the empty set, which +// matches no rows — when the claim is absent, or when any value is unrenderable +// (claimString): one poisoned element could match another principal's rows if +// rendered, and dropping just it would silently narrow the grant. func resolveInValues(tmpl string, claims map[string]any) []any { if m := wholeClaimRe.FindStringSubmatch(strings.TrimSpace(tmpl)); m != nil { switch v := navigateClaims(claims, strings.Split(m[1], ".")).(type) { @@ -345,14 +412,26 @@ func resolveInValues(tmpl string, claims map[string]any) []any { case []any: out := make([]any, 0, len(v)) for _, e := range v { - out = append(out, fmt.Sprint(e)) + s, ok := claimString(e) + if !ok { + return nil + } + out = append(out, s) } return out default: - return []any{fmt.Sprint(v)} + s, ok := claimString(v) + if !ok { + return nil + } + return []any{s} } } - return []any{resolveTemplate(tmpl, claims)} + v, ok := resolveTemplate(tmpl, claims) + if !ok { + return nil + } + return []any{v} } // navigateClaims traverses nested claim maps using dot-separated path parts. diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index 4cebcd9b..f569eff9 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -1,6 +1,7 @@ package policy import ( + "encoding/json" "fmt" "testing" "time" @@ -390,28 +391,59 @@ func TestNavigateClaims(t *testing.T) { } } +// resolved renders a template, requiring the renderable (ok=true) path — the +// counterpart tests for unrenderable claims assert ok=false explicitly. +func resolved(t *testing.T, tmpl string, claims map[string]any) string { + t.Helper() + s, ok := resolveTemplate(tmpl, claims) + require.True(t, ok, "template %q must be renderable", tmpl) + return s +} + func TestResolveTemplate(t *testing.T) { t.Parallel() claims := map[string]any{ "org_id": "org-123", "nested": map[string]any{"val": "deep"}, } - assert.Equal(t, "org-123", resolveTemplate("{{ jwt.org_id }}", claims)) - assert.Equal(t, "deep", resolveTemplate("{{ jwt.nested.val }}", claims)) - assert.Equal(t, "", resolveTemplate("{{ jwt.missing }}", claims)) - assert.Equal(t, "literal", resolveTemplate("literal", claims)) + assert.Equal(t, "org-123", resolved(t, "{{ jwt.org_id }}", claims)) + assert.Equal(t, "deep", resolved(t, "{{ jwt.nested.val }}", claims)) + assert.Equal(t, "", resolved(t, "{{ jwt.missing }}", claims)) + assert.Equal(t, "literal", resolved(t, "literal", claims)) } func TestResolveTemplate_NilClaims(t *testing.T) { t.Parallel() - assert.Equal(t, "", resolveTemplate("{{ jwt.org_id }}", nil)) + assert.Equal(t, "", resolved(t, "{{ jwt.org_id }}", nil)) } func TestResolveTemplate_MultipleTemplates(t *testing.T) { t.Parallel() claims := map[string]any{"a": "1", "b": "2"} - result := resolveTemplate("{{ jwt.a }}-{{ jwt.b }}", claims) - assert.Equal(t, "1-2", result) + assert.Equal(t, "1-2", resolved(t, "{{ jwt.a }}-{{ jwt.b }}", claims)) +} + +// TestResolveTemplate_NumericClaims pins how numeric claims render into filter +// and check constants: json.Number (what jwt.Parse yields since WithJSONNumber) +// passes through with its exact digits; a float64 below 2^53 renders positionally +// (fmt.Sprint's "1e+06" spelling would bind a constant ClickHouse integer columns +// reject); a float64 at or past 2^53 has already collapsed onto its float +// neighbors, so it is unrenderable — ok=false — rather than a stand-in that could +// name another principal. +func TestResolveTemplate_NumericClaims(t *testing.T) { + t.Parallel() + claims := map[string]any{ + "exact": json.Number("10000000000000001"), + "round": float64(1_000_000), + "frac": float64(1.5), + "big": float64(10000000000000001), // already 1e16 by the time it's a float64 + } + assert.Equal(t, "10000000000000001", resolved(t, "{{ jwt.exact }}", claims)) + assert.Equal(t, "1000000", resolved(t, "{{ jwt.round }}", claims)) + assert.Equal(t, "1.5", resolved(t, "{{ jwt.frac }}", claims)) + + _, ok := resolveTemplate("{{ jwt.big }}", claims) + assert.False(t, ok, "a float64 at/past 2^53 lost its digits upstream — refuse, never guess") } func TestValidate(t *testing.T) { @@ -643,6 +675,34 @@ func TestValidate_RejectsBindUnsafeFilterColumn(t *testing.T) { assert.Contains(t, err.Error(), "contains '?'") } +// TestResolveFilters_NumericClaimBinding pins the SQL surface of claimString: a +// json.Number claim binds its exact digits; a round float64 below 2^53 binds +// positionally ("1000000", never the "1e+06" ClickHouse integer columns reject); +// an unrenderable claim — float64 at/past 2^53, alone or as one element of an +// _in set — renders the whole predicate as `1 = 0`, matching no rows, the same +// verdict RowVisible reaches in memory. +func TestResolveFilters_NumericClaimBinding(t *testing.T) { + t.Parallel() + eq := map[string]Filter{"tenant_id": {Eq: new("{{ jwt.tenant }}")}} + + clauses, params := resolveFilters(eq, map[string]any{"tenant": json.Number("10000000000000001")}) + require.Equal(t, []string{"`tenant_id` = ?"}, clauses) + assert.Equal(t, []any{"10000000000000001"}, params, "json.Number binds exact digits") + + clauses, params = resolveFilters(eq, map[string]any{"tenant": float64(1_000_000)}) + require.Equal(t, []string{"`tenant_id` = ?"}, clauses) + assert.Equal(t, []any{"1000000"}, params, "small float binds positionally, not 1e+06") + + clauses, params = resolveFilters(eq, map[string]any{"tenant": float64(10000000000000001)}) + assert.Equal(t, []string{"1 = 0"}, clauses, "lossy float64 claim matches no rows") + assert.Empty(t, params) + + in := map[string]Filter{"tenant_id": {In: new("{{ jwt.tenants }}")}} + clauses, params = resolveFilters(in, map[string]any{"tenants": []any{"a", float64(1 << 60)}}) + assert.Equal(t, []string{"1 = 0"}, clauses, "one poisoned element resolves the whole set empty") + assert.Empty(t, params) +} + // TestResolveFilters_InArrayClaim: the headline #224 fix — an _in filter whose // value is a single array-valued claim expands to `col IN (?, …)` with one bound // param per element, scoping the role to that set instead of producing no diff --git a/internal/policy/rowfilter.go b/internal/policy/rowfilter.go index f570e747..ce4ebe2b 100644 --- a/internal/policy/rowfilter.go +++ b/internal/policy/rowfilter.go @@ -6,6 +6,7 @@ import ( "math/big" "strconv" "strings" + "time" ) // HasRowFilter reports whether this role/table entry carries a row-level-security @@ -27,7 +28,7 @@ type ColumnKind uint8 const ( // ColumnOpaque: no usable type knowledge (no schema, unknown column) or a type // whose text rendering is not canonical — UUID (case), Enum (name vs number), - // Bool (true vs 1), Date/DateTime (formats), IPv4/IPv6, … For these only + // Bool (true vs 1), Date/Date32 (producer spelling), IPv4/IPv6, … For these only // byte-equality is trustworthy: identical strings parse to identical ClickHouse // values, but differing strings prove nothing. So = and in admit exactly the // event's own rendering, while !=, > and < fail closed (the row is withheld). @@ -39,22 +40,49 @@ const ( // ClickHouse comparison — equality AND lexicographic order — so every operator // is exact. FixedString is NOT ColumnText (zero-padded storage). ColumnText + // ColumnTime (DateTime/DateTime64): operands parse as instants through the + // caller-supplied ColumnSpec.ParseTime — the same grammar, zone rule, and + // range guard ingest canonicalization applies — and compare chronologically, + // so every operator is exact across spellings: a zone-less filter constant + // matches the canonicalized RFC 3339 payload denoting the same instant. A + // side that can't be read as a provable instant fails closed. + ColumnTime ) +// ColumnSpec is one column's comparison contract for the in-memory row filter: +// the ColumnKind classification plus, for ColumnTime, the parser that maps a +// value rendering to an instant. The zero value is ColumnOpaque with no parser, +// so a nil map, an absent column, and an unknown type all land on the most +// conservative class — never a silent downgrade to a laxer comparison. +type ColumnSpec struct { + Kind ColumnKind + // ParseTime converts one rendering of this timestamp column's value — an + // ingested payload value (string / json.Number / float64) or a resolved + // filter constant (always a string) — to the instant ClickHouse would store, + // truncated to the column's precision. ok=false (unparseable, or outside the + // column type's range, which insert-time saturation would move) fails the + // comparison closed. Set iff Kind is ColumnTime; the stream supplies it from + // the schema registry (discovery's Column.TimeParser) so the filter and + // ingest canonicalization can never disagree on the grammar. + ParseTime func(v any) (t time.Time, ok bool) +} + // RowVisible reports whether row satisfies every resolved row-filter predicate — the // in-memory twin of the query path's WHERE clause, evaluated against a decoded event // so the stream applies the same row-level security the query path does. Predicates // are ANDed; the query path joins them with AND too. // -// colKinds maps column name → ColumnKind, supplied by the caller from the table -// schema (see stream.Hub's columnKinds). Numeric columns compare numerically (9 < +// cols maps column name → ColumnSpec, supplied by the caller from the table +// schema (see stream.Hub's columnSpecs). Numeric columns compare numerically (9 < // 100, as ClickHouse would), String columns compare bytewise (exactly ClickHouse's -// String collation), and everything else — including every column when no schema is -// available — admits only byte-equality (= / in) and fails !=, > and < closed: the -// evaluator cannot mirror ClickHouse's per-type coercion, and text comparison there -// could admit rows the query path excludes ("9" > "100" as text, an uppercase UUID -// under !=). Every ambiguous or uncomparable case fails closed — the row is hidden, -// never leaked — so the boundary costs availability, not confidentiality. +// String collation), DateTime/DateTime64 columns compare as instants (both operands +// parsed through the spec's ParseTime, the same grammar ingest canonicalizes with), +// and everything else — including every column when no schema is available — admits +// only byte-equality (= / in) and fails !=, > and < closed: the evaluator cannot +// mirror ClickHouse's per-type coercion, and text comparison there could admit rows +// the query path excludes ("9" > "100" as text, an uppercase UUID under !=). Every +// ambiguous or uncomparable case fails closed — the row is hidden, never leaked — +// so the boundary costs availability, not confidentiality. // // That guarantee is about the INGESTED PAYLOAD value, which is what the stream // evaluates; the query path evaluates the stored row. The one residual asymmetry is @@ -65,7 +93,7 @@ const ( // caution. // // A nil receiver (no policy applies) makes every row visible. -func (p *ResolvedPermissions) RowVisible(row map[string]any, colKinds map[string]ColumnKind) bool { +func (p *ResolvedPermissions) RowVisible(row map[string]any, cols map[string]ColumnSpec) bool { if p == nil { return true } @@ -75,7 +103,7 @@ func (p *ResolvedPermissions) RowVisible(row map[string]any, colKinds map[string return false } for _, pred := range p.rowFilter { - if !pred.matches(row, colKinds[pred.Column]) { + if !pred.matches(row, cols[pred.Column]) { return false } } @@ -84,27 +112,33 @@ func (p *ResolvedPermissions) RowVisible(row map[string]any, colKinds map[string // matches evaluates one predicate against the row, failing closed (false) whenever // the value is absent or can't be compared as required. -func (pred resolvedPredicate) matches(row map[string]any, kind ColumnKind) bool { +func (pred resolvedPredicate) matches(row map[string]any, spec ColumnSpec) bool { + // No values ⇒ matches nothing: an empty/unresolvable "in" set, or a scalar + // whose constant was unrenderable — the in-memory twin of the `1 = 0` + // predicatesToSQL emits for the same cases. + if len(pred.Values) == 0 { + return false + } raw, ok := row[pred.Column] if !ok { return false // column not in the event ⇒ can't prove the row is allowed } switch pred.Op { case "=": - c, ok := compareScalar(raw, pred.Values[0], kind) + c, ok := compareScalar(raw, pred.Values[0], spec) return ok && c == 0 case "!=": - c, ok := compareScalar(raw, pred.Values[0], kind) + c, ok := compareScalar(raw, pred.Values[0], spec) return ok && c != 0 case ">": - c, ok := compareScalar(raw, pred.Values[0], kind) + c, ok := compareScalar(raw, pred.Values[0], spec) return ok && c > 0 case "<": - c, ok := compareScalar(raw, pred.Values[0], kind) + c, ok := compareScalar(raw, pred.Values[0], spec) return ok && c < 0 case "in": for _, v := range pred.Values { - if c, ok := compareScalar(raw, v, kind); ok && c == 0 { + if c, ok := compareScalar(raw, v, spec); ok && c == 0 { return true } } @@ -121,13 +155,31 @@ func (pred resolvedPredicate) matches(row map[string]any, kind ColumnKind) bool // ok=false fails the enclosing predicate closed — for != that is what keeps a mere // representation difference (an uppercase UUID, 1 for a Bool true) from being // mistaken for a real inequality and admitting a row the query path excludes. -func compareScalar(rowVal any, filterVal string, kind ColumnKind) (int, bool) { - s, ok := scalarString(rowVal) - if !ok { - return 0, false - } - switch kind { +func compareScalar(rowVal any, filterVal string, spec ColumnSpec) (int, bool) { + switch spec.Kind { + case ColumnTime: + // The RAW value goes to the parser — a payload timestamp may legitimately + // be a number (Unix seconds/ticks), which the spec's parser reads the same + // way ingest does; scalarString's rendering would be a detour. Both sides + // must parse; either failing (or a spec missing its parser) refuses the + // comparison, fail closed. + if spec.ParseTime == nil { + return 0, false + } + a, ok := spec.ParseTime(rowVal) + if !ok { + return 0, false + } + b, ok := spec.ParseTime(filterVal) + if !ok { + return 0, false + } + return a.Compare(b), true case ColumnNumeric: + s, ok := scalarString(rowVal) + if !ok { + return 0, false + } a, err1 := strconv.ParseFloat(s, 64) b, err2 := strconv.ParseFloat(filterVal, 64) // NaN must be rejected explicitly: ParseFloat accepts "NaN", and NaN's @@ -150,12 +202,20 @@ func compareScalar(rowVal any, filterVal string, kind ColumnKind) (int, bool) { return compareExact(s, filterVal) } case ColumnText: + s, ok := scalarString(rowVal) + if !ok { + return 0, false + } return strings.Compare(s, filterVal), true case ColumnOpaque: // Byte-equality is the only relation provable without type knowledge // (identical strings always parse to the same ClickHouse value); unequal // bytes prove nothing, so the comparison is refused and the predicate // fails closed. + s, ok := scalarString(rowVal) + if !ok { + return 0, false + } if s == filterVal { return 0, true } diff --git a/internal/policy/rowfilter_test.go b/internal/policy/rowfilter_test.go index 45ee3505..38313961 100644 --- a/internal/policy/rowfilter_test.go +++ b/internal/policy/rowfilter_test.go @@ -3,6 +3,7 @@ package policy import ( "encoding/json" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -52,7 +53,7 @@ func TestRowVisible_InSet(t *testing.T) { // than admit a row the query path excludes. func TestRowVisible_Neq(t *testing.T) { t.Parallel() - text := map[string]ColumnKind{"status": ColumnText} + text := map[string]ColumnSpec{"status": {Kind: ColumnText}} perms := evalRowFilter(t, map[string]Filter{"status": {Neq: new("deleted")}}, nil) assert.True(t, perms.RowVisible(map[string]any{"status": "active"}, text), "String column: byte inequality is real inequality") assert.False(t, perms.RowVisible(map[string]any{"status": "deleted"}, text)) @@ -61,11 +62,11 @@ func TestRowVisible_Neq(t *testing.T) { "no schema: byte inequality proves nothing, fail closed") uuid := evalRowFilter(t, map[string]Filter{"device": {Neq: new("ABC-DEF")}}, nil) - assert.False(t, uuid.RowVisible(map[string]any{"device": "abc-def"}, map[string]ColumnKind{"device": ColumnOpaque}), + assert.False(t, uuid.RowVisible(map[string]any{"device": "abc-def"}, map[string]ColumnSpec{"device": {Kind: ColumnOpaque}}), "opaque column (e.g. UUID): a case difference is not proof of inequality — fail closed") num := evalRowFilter(t, map[string]Filter{"amount": {Neq: new("100")}}, nil) - kinds := map[string]ColumnKind{"amount": ColumnNumeric} + kinds := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} assert.True(t, num.RowVisible(map[string]any{"amount": float64(250)}, kinds), "numeric column: 250 ≠ 100 is provable") assert.False(t, num.RowVisible(map[string]any{"amount": float64(100)}, kinds)) } @@ -83,21 +84,21 @@ func TestRowVisible_Ordering_SchemaInformed(t *testing.T) { perms := evalRowFilter(t, map[string]Filter{"amount": {Gt: new("100")}}, nil) small := map[string]any{"amount": float64(9)} big := map[string]any{"amount": float64(250)} - numeric := map[string]ColumnKind{"amount": ColumnNumeric} + numeric := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} assert.False(t, perms.RowVisible(small, numeric), "numeric: 9 is not > 100") assert.True(t, perms.RowVisible(big, numeric)) assert.False(t, perms.RowVisible(small, nil), `no schema: fail closed — never the "9" > "100" text leak`) assert.False(t, perms.RowVisible(big, nil), "no schema: fail closed even when the numbers would pass") - assert.False(t, perms.RowVisible(small, map[string]ColumnKind{"other": ColumnNumeric}), + assert.False(t, perms.RowVisible(small, map[string]ColumnSpec{"other": {Kind: ColumnNumeric}}), "column absent from a known schema: fail closed") - assert.False(t, perms.RowVisible(small, map[string]ColumnKind{"amount": ColumnOpaque}), + assert.False(t, perms.RowVisible(small, map[string]ColumnSpec{"amount": {Kind: ColumnOpaque}}), "opaque type (Enum/Date/UUID/…): order is unprovable, fail closed") // String columns order bytewise in ClickHouse, so ordering there is exact. page := evalRowFilter(t, map[string]Filter{"page": {Gt: new("/m")}}, nil) - text := map[string]ColumnKind{"page": ColumnText} + text := map[string]ColumnSpec{"page": {Kind: ColumnText}} assert.True(t, page.RowVisible(map[string]any{"page": "/z"}, text)) assert.False(t, page.RowVisible(map[string]any{"page": "/a"}, text)) } @@ -108,7 +109,7 @@ func TestRowVisible_Ordering_SchemaInformed(t *testing.T) { func TestRowVisible_NumericEquality_FloatFormatting(t *testing.T) { t.Parallel() perms := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("100")}}, nil) - num := map[string]ColumnKind{"amount": ColumnNumeric} + num := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} assert.True(t, perms.RowVisible(map[string]any{"amount": float64(100)}, num)) assert.False(t, perms.RowVisible(map[string]any{"amount": float64(101)}, num)) } @@ -119,7 +120,7 @@ func TestRowVisible_NumericEquality_FloatFormatting(t *testing.T) { // NaN must withhold the row instead. func TestRowVisible_NaN_FailsClosed(t *testing.T) { t.Parallel() - num := map[string]ColumnKind{"amount": ColumnNumeric} + num := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} byRow := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("100")}}, nil) assert.False(t, byRow.RowVisible(map[string]any{"amount": "NaN"}, num), "NaN row value must not equal 100") @@ -136,7 +137,7 @@ func TestRowVisible_NaN_FailsClosed(t *testing.T) { // collapse distinct IDs that round to the same float64 (adjacent values past 2^53). func TestRowVisible_NumericEquality_ExactBeyondFloat64(t *testing.T) { t.Parallel() - num := map[string]ColumnKind{"id": ColumnNumeric} + num := map[string]ColumnSpec{"id": {Kind: ColumnNumeric}} perms := evalRowFilter(t, map[string]Filter{"id": {Eq: new("9007199254740993")}}, nil) assert.False(t, perms.RowVisible(map[string]any{"id": "9007199254740992"}, num), "float64-equal neighbors are not equal") @@ -154,6 +155,72 @@ func TestRowVisible_NumericEquality_ExactBeyondFloat64(t *testing.T) { assert.True(t, neq.RowVisible(map[string]any{"id": "9007199254740992"}, num), "the exact tie-break keeps distinct IDs unequal for !=") } +// TestRowVisible_LossyFloatClaim_FailsClosed: a claim that arrives as a float64 +// at or past 2^53 has already collapsed onto its float neighbors — rendering +// digits for it could name another tenant (the fail-open caught in #381 review: +// fmt.Sprint turned the claim into "1e+16", which compareExact then matched +// against the neighbor's exact digits). The predicate must match NOTHING: not +// the neighbor the float equals, and not even the row whose exact ID the claim +// originally carried — availability, never confidentiality. +func TestRowVisible_LossyFloatClaim_FailsClosed(t *testing.T) { + t.Parallel() + num := map[string]ColumnSpec{"tenant_id": {Kind: ColumnNumeric}} + filter := map[string]Filter{"tenant_id": {Eq: new("{{ jwt.tenant }}")}} + + lossy := evalRowFilter(t, filter, map[string]any{"tenant": float64(10000000000000001)}) // already 1e16 + assert.False(t, lossy.RowVisible(map[string]any{"tenant_id": json.Number("10000000000000000")}, num), + "the float64-equal neighbor tenant's rows must not be delivered") + assert.False(t, lossy.RowVisible(map[string]any{"tenant_id": json.Number("10000000000000001")}, num), + "the original tenant's own rows are withheld too — the digits are unrecoverable") + + // The exact-digit path — a json.Number claim, which is what jwt.Parse yields + // since WithJSONNumber — scopes to precisely one tenant. + exact := evalRowFilter(t, filter, map[string]any{"tenant": json.Number("10000000000000001")}) + assert.True(t, exact.RowVisible(map[string]any{"tenant_id": json.Number("10000000000000001")}, num)) + assert.False(t, exact.RowVisible(map[string]any{"tenant_id": json.Number("10000000000000000")}, num)) +} + +// rfc3339Spec is a ColumnTime spec whose parser reads RFC 3339 strings — a +// hermetic stand-in for discovery's grammar, which is exercised in +// internal/discovery (Column.TimeParser) and end-to-end in the hub tests. +func rfc3339Spec() ColumnSpec { + return ColumnSpec{Kind: ColumnTime, ParseTime: func(v any) (time.Time, bool) { + s, ok := v.(string) + if !ok { + return time.Time{}, false + } + ts, err := time.Parse(time.RFC3339, s) + return ts, err == nil + }} +} + +// TestRowVisible_TimeColumn: DateTime/DateTime64 operands compare as instants, +// so equality holds across spellings of the same instant, ordering works (the +// time-window policy shape), and any operand the parser refuses — junk on either +// side, or a ColumnTime spec missing its parser — withholds the row. +func TestRowVisible_TimeColumn(t *testing.T) { + t.Parallel() + cols := map[string]ColumnSpec{"created_at": rfc3339Spec()} + + eq := evalRowFilter(t, map[string]Filter{"created_at": {Eq: new("2026-06-21T06:00:00+02:00")}}, nil) + assert.True(t, eq.RowVisible(map[string]any{"created_at": "2026-06-21T04:00:00Z"}, cols), + "different spelling, same instant ⇒ equal") + assert.False(t, eq.RowVisible(map[string]any{"created_at": "2026-06-21T04:00:01Z"}, cols)) + assert.False(t, eq.RowVisible(map[string]any{"created_at": "junk"}, cols), "unparseable payload withholds") + + gt := evalRowFilter(t, map[string]Filter{"created_at": {Gt: new("2026-06-21T00:00:00Z")}}, nil) + assert.True(t, gt.RowVisible(map[string]any{"created_at": "2026-06-21T04:00:00Z"}, cols)) + assert.False(t, gt.RowVisible(map[string]any{"created_at": "2026-06-20T04:00:00Z"}, cols)) + + bad := evalRowFilter(t, map[string]Filter{"created_at": {Eq: new("not-a-time")}}, nil) + assert.False(t, bad.RowVisible(map[string]any{"created_at": "2026-06-21T04:00:00Z"}, cols), + "unparseable constant withholds") + + noParser := map[string]ColumnSpec{"created_at": {Kind: ColumnTime}} + assert.False(t, eq.RowVisible(map[string]any{"created_at": "2026-06-21T04:00:00Z"}, noParser), + "ColumnTime without a parser refuses the comparison — never a text fallback") +} + func TestRowVisible_NilReceiver_AllVisible(t *testing.T) { t.Parallel() var perms *ResolvedPermissions @@ -178,7 +245,7 @@ func TestRowVisible_MultiplePredicates_AllMustPass(t *testing.T) { "tenant_id": {Eq: new("{{ jwt.tenant }}")}, "amount": {Gt: new("100")}, }, map[string]any{"tenant": "acme"}) - num := map[string]ColumnKind{"amount": ColumnNumeric} + num := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} assert.True(t, perms.RowVisible(map[string]any{"tenant_id": "acme", "amount": float64(250)}, num)) assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "acme", "amount": float64(9)}, num), "amount fails") assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "globex", "amount": float64(250)}, num), "tenant fails") diff --git a/internal/stream/hub.go b/internal/stream/hub.go index 31f3f7ab..619fae6c 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -27,7 +27,7 @@ type Hub struct { mu sync.RWMutex topics map[string]*topicRoutes policy *policy.Store // nil ⇒ policy filtering not configured (legacy passthrough) - registry *discovery.SchemaRegistry // nil ⇒ no column types; row-filter comparison degrades fail-closed (see columnKinds) + registry *discovery.SchemaRegistry // nil ⇒ no column types; row-filter comparison degrades fail-closed (see columnSpecs) metric *Metrics // nil-safe } @@ -135,11 +135,11 @@ func (h *Hub) Broadcast(topic string, raw []byte) { decoded := decodeEvent(raw, &evt) p, filter := h.snapshotPolicy() - // Column kinds for type-aware row-filter comparison — resolved lazily at most + // Column specs for type-aware row-filter comparison — resolved lazily at most // once per event, only when some role actually carries a row-filter, and reused // across every filtered role and subscriber. - var colKinds map[string]policy.ColumnKind - kindsResolved := false + var colSpecs map[string]policy.ColumnSpec + specsResolved := false for _, rb := range roleBuckets { wire, perms, ok := projectColumns(p, filter, rb.role, &evt, raw, decoded) @@ -163,13 +163,13 @@ func (h *Hub) Broadcast(topic string, raw []byte) { // shared, but whether each subscriber may see THIS row depends on its claims, so // evaluate visibility per subscriber. Predicates read the full event data (a // filter may key on a column the role can't SELECT), not the projected columns. - if !kindsResolved { - colKinds = h.columnKinds(evt.TableName) - kindsResolved = true + if !specsResolved { + colSpecs = h.columnSpecs(evt.TableName) + specsResolved = true } for _, sub := range rb.bucket.Snapshot() { subPerms := policy.Evaluate(p, rb.role, evt.TableName, "select", sub.claims) - if !subPerms.RowVisible(evt.Data, colKinds) { + if !subPerms.RowVisible(evt.Data, colSpecs) { h.metric.RowWithheld(evt.TableName, rb.role) continue // this row is filtered out for this subscriber } @@ -180,14 +180,17 @@ func (h *Hub) Broadcast(topic string, raw []byte) { } } -// columnKinds classifies each of the table's columns for the row-filter evaluator: -// numeric types compare numerically (9 < 100, matching ClickHouse), String compares -// bytewise (exactly ClickHouse's String collation), and any other type is omitted — +// columnSpecs classifies each of the table's columns for the row-filter evaluator: +// DateTime/DateTime64 columns compare as instants (through discovery's +// Column.TimeParser — the same grammar ingest canonicalization applies, so a +// zone-less filter constant matches the canonical RFC 3339 payload), numeric types +// compare numerically (9 < 100, matching ClickHouse), String compares bytewise +// (exactly ClickHouse's String collation), and any other type is omitted — // policy.ColumnOpaque, the map's zero value — admitting byte-equality only. nil when // no schema is available (unknown table, or a Hub built without a registry), which // reads as every column Opaque: the fail-closed floor, never a lexicographic // fallback that could admit rows the query path excludes ("9" > "100" as text). -func (h *Hub) columnKinds(table string) map[string]policy.ColumnKind { +func (h *Hub) columnSpecs(table string) map[string]policy.ColumnSpec { if h.registry == nil { return nil } @@ -195,13 +198,17 @@ func (h *Hub) columnKinds(table string) map[string]policy.ColumnKind { if schema == nil { return nil } - m := make(map[string]policy.ColumnKind, len(schema.Columns)) + m := make(map[string]policy.ColumnSpec, len(schema.Columns)) for _, c := range schema.Columns { + if pt := c.TimeParser(); pt != nil { + m[c.Name] = policy.ColumnSpec{Kind: policy.ColumnTime, ParseTime: pt} + continue + } switch { case discovery.IsNumericType(c.Type): - m[c.Name] = policy.ColumnNumeric + m[c.Name] = policy.ColumnSpec{Kind: policy.ColumnNumeric} case discovery.IsStringType(c.Type): - m[c.Name] = policy.ColumnText + m[c.Name] = policy.ColumnSpec{Kind: policy.ColumnText} } } return m @@ -254,8 +261,8 @@ func (h *Hub) snapshotPolicy() (p *policy.Policy, filter bool) { // build per event. The closure is for a single goroutine — each connection makes // its own. The live path uses Broadcast. func (h *Hub) ReplayProjector(role string, claims map[string]any) func(raw []byte) (Frame, bool) { - var colKinds map[string]policy.ColumnKind - kindsFor := "" // table name colKinds was resolved for ("" ⇒ not yet resolved) + var colSpecs map[string]policy.ColumnSpec + specsFor := "" // table name colSpecs was resolved for ("" ⇒ not yet resolved) return func(raw []byte) (Frame, bool) { var evt ingest.EventMessage decoded := decodeEvent(raw, &evt) @@ -267,12 +274,12 @@ func (h *Hub) ReplayProjector(role string, claims map[string]any) func(raw []byt if perms.HasRowFilter() { // One topic ⇒ one table, so this resolves once per replay in practice; the // guard re-resolves if a stream ever mixes tables rather than going stale. - if kindsFor != evt.TableName { - colKinds = h.columnKinds(evt.TableName) - kindsFor = evt.TableName + if specsFor != evt.TableName { + colSpecs = h.columnSpecs(evt.TableName) + specsFor = evt.TableName } subPerms := policy.Evaluate(p, role, evt.TableName, "select", claims) - if !subPerms.RowVisible(evt.Data, colKinds) { + if !subPerms.RowVisible(evt.Data, colSpecs) { h.metric.RowWithheld(evt.TableName, role) return Frame{}, false // this row is filtered out for these claims } diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index ac23ddc0..b92db0fb 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -4,11 +4,14 @@ import ( "context" "encoding/json" "fmt" + "net/http" + "net/http/httptest" "strings" "sync" "testing" "time" + "github.com/Wave-RF/WaveHouse/internal/auth" "github.com/Wave-RF/WaveHouse/internal/discovery" "github.com/Wave-RF/WaveHouse/internal/ingest" "github.com/Wave-RF/WaveHouse/internal/policy" @@ -20,6 +23,28 @@ import ( "go.opentelemetry.io/otel/sdk/metric/metricdata" ) +// jwtClaims round-trips claims through a real signed token and the production +// auth middleware, returning them exactly as a live connection would carry them +// (numbers as json.Number, never float64 or a hand-typed string). Any test that +// asserts a claims-derived guarantee must build its subscriber this way: a +// hand-built claims map once used string tenants here and passed while the +// production decode path failed open (#381 review). +func jwtClaims(t *testing.T, claims map[string]any) map[string]any { + t.Helper() + mw, err := auth.Middleware(auth.Config{JWTSecret: testutil.TestJWTSecret}, nil, nil) + require.NoError(t, err) + var got map[string]any + h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + c, ok := auth.ClaimsFromContext(r.Context()) + require.True(t, ok, "test token must authenticate") + got = map[string]any(c) + })) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+testutil.MakeJWT(t, claims)) + h.ServeHTTP(httptest.NewRecorder(), req) + return got +} + // rawEvent marshals an EventMessage the way the ingest path publishes it. // testing.TB so the fan-out benchmark can build events too. func rawEvent(tb testing.TB, table, ts string, data map[string]any) []byte { @@ -599,8 +624,12 @@ func TestHub_RowFilter_BigIntegerExact(t *testing.T) { hub := NewHub(policy.NewMemoryStore(p), reg, nil) const topic = "ingest.clicks" - neighbor := NewSubscriber(map[string]any{"tenant": "10000000000000000"}) // float64-equal neighbor - exact := NewSubscriber(map[string]any{"tenant": "10000000000000001"}) + // Claims come from real signed tokens through the production middleware, so a + // bare JSON-number tenant claim reaches the filter exactly as production + // decodes it (json.Number since WithJSONNumber; before that fix, float64 — + // which collapsed these neighbors and delivered the cross-tenant row). + neighbor := NewSubscriber(jwtClaims(t, map[string]any{"tenant": json.Number("10000000000000000")})) + exact := NewSubscriber(jwtClaims(t, map[string]any{"tenant": json.Number("10000000000000001")})) hub.Add(topic, "viewer", neighbor) hub.Add(topic, "viewer", exact) @@ -613,6 +642,46 @@ func TestHub_RowFilter_BigIntegerExact(t *testing.T) { "the wire frame carries the exact digits, not a float64 rounding") } +// TestHub_RowFilter_TimestampInstantMatch: since #402, ingest canonicalizes +// DateTime/DateTime64 payload values to RFC 3339 UTC before publish, while policy +// authors write the ClickHouse-friendly zone-less spelling the query path wants. +// The row filter compares the two as instants through the discovery grammar (one +// parser shared with canonicalization), so the spellings agree; an operand the +// grammar can't read withholds the row. +func TestHub_RowFilter_TimestampInstantMatch(t *testing.T) { + t.Parallel() + reg := testutil.NewTestSchemaRegistry(t, []*discovery.TableSchema{ + {Name: "clicks", Columns: []discovery.Column{ + {Name: "created_at", Type: "DateTime"}, + {Name: "page", Type: "String"}, + }}, + }) + p := &policy.Policy{ + Tables: map[string]policy.TablePolicy{ + "clicks": {Select: map[string]policy.RolePermissions{ + // Zone-less constant, read in the column's zone (UTC here) on both + // surfaces — the one spelling that works for the query path's SQL too. + "viewer": {Filter: map[string]policy.Filter{"created_at": {Eq: new("2026-06-21 04:00:00")}}}, + }}, + }, + } + hub := NewHub(policy.NewMemoryStore(p), reg, nil) + const topic = "ingest.clicks" + + sub := NewSubscriber(nil) + hub.Add(topic, "viewer", sub) + + // The canonical wire spelling ingest publishes: same instant, different bytes. + hub.Broadcast(topic, rawEvent(t, "clicks", "t1", map[string]any{"created_at": "2026-06-21T04:00:00Z", "page": "/a"})) + assert.NotEmpty(t, recvFrame(t, sub).Data, "canonical payload matches the zone-less constant as an instant") + + hub.Broadcast(topic, rawEvent(t, "clicks", "t2", map[string]any{"created_at": "2026-06-21T04:00:01Z", "page": "/a"})) + assertNoFrame(t, sub) + + hub.Broadcast(topic, rawEvent(t, "clicks", "t3", map[string]any{"created_at": "not a timestamp", "page": "/a"})) + assertNoFrame(t, sub) +} + // TestHub_RowFilterWithheldIncrementsMetric: a row withheld by row-level security is // otherwise invisible to operators (it is not a dropped frame — the queue was never // tried). The wavehouse_sse_rows_withheld_total counter must tick for live fan-out From 2abde27f06cde0505098d2ace7ceb3f5facc8111 Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 12 Aug 2026 14:04:58 -0400 Subject: [PATCH 21/27] docs: row-filter prose fixes, drop out-of-scope SDK docs --- CHANGELOG.md | 2 +- clients/ts/README.md | 2 +- docs/src/content/docs/access-control.mdx | 4 ++-- docs/src/content/docs/api.md | 2 +- docs/src/content/docs/architecture.md | 4 ++-- docs/src/content/docs/sdk/reference.md | 5 ++--- docs/src/content/docs/sdk/streaming.md | 11 ----------- 7 files changed, 9 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c82cce54..8ecaf4b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/stream/metrics.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/api.md`, `AGENTS.md`, `SECURITY.md`, `internal/stream/doc.go`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/discovery/validation_test.go`, `internal/stream/hub_test.go`, `tests/e2e/sdk/streaming.test.ts`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). The in-memory comparison is type-aware and **fails closed on anything it can't prove** (`policy.ColumnKind`, seeded from the schema registry): numeric columns (`Int*`/`UInt*`/`Float*`/`Decimal*`) compare numerically, matching ClickHouse — float64-equal ties resolve at arbitrary precision (`math/big`) and the hub decodes event payloads with `UseNumber` (exact digit strings, also keeping big integers byte-faithful on the SSE wire), so 64-bit IDs never falsely collide whether they arrive string-encoded (the JS-precision-loss escape hatch ingest accepts) or as bare JSON numbers, and an unparseable/`NaN` operand withholds the row — `String` columns compare bytewise (exactly ClickHouse's String semantics, equality *and* ordering) — `DateTime`/`DateTime64` columns compare as **instants**, both operands read through the very grammar ingest canonicalization (#402) uses (`discovery.Column.TimeParser`: same spellings and zone rule, truncated to the column's precision, out-of-range instants that insert-time saturation would move refused), so a zone-less filter constant — the spelling query-path SQL wants — matches the canonical RFC 3339 payload the stream carries, time-window `_gt`/`_lt` policies work, and either operand the grammar can't read withholds the row — and every other type (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, IPs, `FixedString`, …) — plus every column when no schema is available (an unknown table, or the boot-time discovery-failure window where the server serves while retrying) — trusts byte-equality only: `_eq`/`_in` admit exactly the event's own text rendering, while `_neq`/`_gt`/`_lt` withhold the row, because a byte difference can be pure representation and admitting on it would deliver rows the query path excludes (an uppercase UUID under `_neq`; `"9" > "100"` as text under `_gt`). The filter *constants* are claims-derived, so the same exactness holds on that side too: the auth middleware now parses JWTs with `WithJSONNumber` (`internal/auth/auth.go` — a numeric claim keeps its exact digits as `json.Number` instead of collapsing to float64; before this, a Snowflake-scale `tenant` claim rendered as `"1e+16"` and the stream *delivered the float64-equal neighbor tenant's rows* while withholding the subscriber's own), and the policy engine's claim rendering (`claimString`) is hardened in depth: a float64 at or past 2^53 is refused outright — the predicate then matches **no** rows on either surface (`1 = 0` in SQL, unconditionally false in memory) — and smaller floats render positionally (`"1000000"`, never the `"1e+06"` spelling ClickHouse integer columns reject with a type error), which also fixes the check-clause auto-inject for such claims. Ambiguity therefore always costs availability (a row withheld), never confidentiality — a guarantee about the ingested payload the stream evaluates, whose one payload-vs-stored asymmetry (insert-time numeric narrowing: `Decimal` scale truncation, `Float32` rounding) is documented in the enforcement caution — and each withhold is observable via the new `wavehouse_sse_rows_withheld_total` counter (labeled by table and role, on the live and replay paths alike), separating "no matching rows" from "a fail-closed filter is withholding everything". Subscriber claims are fixed at construction (`stream.NewSubscriber(claims)` — no setter), so the fan-out's unsynchronized claims read is race-free structurally, with a dedicated `-race` test driving concurrent row-filtered broadcasts. Replay (gap-fill) applies the same per-connection row check via `Hub.ReplayProjector`, which caches the per-table column-kind lookup across the replay loop. **Perf note:** on a topic whose role carries a row-filter, part of the #294/#353 once-per-role fan-out gain is traded back for correctness — `policy.Evaluate` runs per subscriber per event there (the column projection stays shared; roles without a filter are unaffected); #435 tracks memoizing it, and `BenchmarkBroadcast_RowFilteredFanout` exists to measure it. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant originally noted in the #294/#353 Changed entry (under **Changed** in this same block, which now points back here). +- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/stream/metrics.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/discovery/timestamp.go`, `internal/auth/auth.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/api.md`, `docs/src/content/docs/sdk/streaming.md`, `AGENTS.md`, `SECURITY.md`, `internal/stream/doc.go`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/policy/policy_test.go`, `internal/discovery/validation_test.go`, `internal/discovery/timestamp_test.go`, `internal/auth/auth_test.go`, `internal/stream/hub_test.go`, `tests/e2e/sdk/streaming.test.ts`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). The in-memory comparison is type-aware and **fails closed on anything it can't prove** (`policy.ColumnKind`, seeded from the schema registry): numeric columns (`Int*`/`UInt*`/`Float*`/`Decimal*`) compare numerically, matching ClickHouse — float64-equal ties resolve at arbitrary precision (`math/big`) and the hub decodes event payloads with `UseNumber` (exact digit strings, also keeping big integers byte-faithful on the SSE wire), so 64-bit IDs never falsely collide whether they arrive string-encoded (the JS-precision-loss escape hatch ingest accepts) or as bare JSON numbers, and an unparseable/`NaN` operand withholds the row — `String` columns compare bytewise (exactly ClickHouse's String semantics, equality *and* ordering) — `DateTime`/`DateTime64` columns compare as **instants**, both operands read through the very grammar ingest canonicalization (#402) uses (`discovery.Column.TimeParser`: same spellings and zone rule, truncated to the column's precision, out-of-range instants that insert-time saturation would move refused), so a zone-less filter constant — the spelling query-path SQL wants — matches the canonical RFC 3339 payload the stream carries, time-window `_gt`/`_lt` policies work, and either operand the grammar can't read withholds the row — and every other type (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, IPs, `FixedString`, …) — plus every column when no schema is available (an unknown table, or the boot-time discovery-failure window where the server serves while retrying) — trusts byte-equality only: `_eq`/`_in` admit exactly the event's own text rendering, while `_neq`/`_gt`/`_lt` withhold the row, because a byte difference can be pure representation and admitting on it would deliver rows the query path excludes (an uppercase UUID under `_neq`; `"9" > "100"` as text under `_gt`). The filter *constants* are claims-derived, so the same exactness holds on that side too: the auth middleware now parses JWTs with `WithJSONNumber` (`internal/auth/auth.go` — a numeric claim keeps its exact digits as `json.Number` instead of collapsing to float64; before this, a Snowflake-scale `tenant` claim rendered as `"1e+16"` and the stream *delivered the float64-equal neighbor tenant's rows* while withholding the subscriber's own), and the policy engine's claim rendering (`claimString`) is hardened in depth: a float64 at or past 2^53 is refused outright — the predicate then matches **no** rows on either surface (`1 = 0` in SQL, unconditionally false in memory) — and smaller floats render positionally (`"1000000"`, never the `"1e+06"` spelling ClickHouse integer columns reject with a type error), which also fixes the check-clause auto-inject for such claims. Ambiguity therefore always costs availability (a row withheld), never confidentiality — a guarantee about the ingested payload the stream evaluates, whose one payload-vs-stored asymmetry (insert-time numeric narrowing: `Decimal` scale truncation, `Float32` rounding) is documented in the enforcement caution — and each withhold is observable via the new `wavehouse_sse_rows_withheld_total` counter (labeled by table and role, on the live and replay paths alike), separating "no matching rows" from "a fail-closed filter is withholding everything". Subscriber claims are fixed at construction (`stream.NewSubscriber(claims)` — no setter), so the fan-out's unsynchronized claims read is race-free structurally, with a dedicated `-race` test driving concurrent row-filtered broadcasts. Replay (gap-fill) applies the same per-connection row check via `Hub.ReplayProjector`, which caches the per-table column-kind lookup across the replay loop. **Perf note:** on a topic whose role carries a row-filter, part of the #294/#353 once-per-role fan-out gain is traded back for correctness — `policy.Evaluate` runs per subscriber per event there (the column projection stays shared; roles without a filter are unaffected); #435 tracks memoizing it, and `BenchmarkBroadcast_RowFilteredFanout` exists to measure it. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant originally noted in the #294/#353 Changed entry (under **Changed** in this same block, which now points back here). - **A `?token=` query credential is now stripped from the request URL whichever credential wins, not only when it is the one used** (`internal/auth/auth.go`, `internal/auth/auth_test.go`, `docs/src/content/docs/api.md`, `docs/src/content/docs/reverse-proxy.mdx`, `docs/src/content/docs/sdk/index.mdx`): raised by CodeRabbit on [#448](https://github.com/Wave-RF/WaveHouse/pull/448). `bearerToken` returned from the `Authorization: Bearer` branch *before* the strip, so a request presenting both credentials — which any caller can do — left the unused JWT sitting in `r.URL` for the rest of the request's life. The operator-key path had the same shape one frame up, returning before `bearerToken` ran at all, so the strip is now resolved ahead of that branch too. Not an active leak today (WaveHouse's own request logging only ever records `r.URL.Path`, and the OTel HTTP instrumentation records no query attribute), so this is defense in depth rather than a fix for an observed exposure — but it closed an inconsistency in an invariant the code already asserted on the query-only path, where any later handler or future logging change would have turned it into one. The strip now runs once, before either credential path returns; header precedence is unchanged, unrelated query parameters survive, and both cases are pinned by tests. The docs correspondingly drop the "the header path leaves the query parameter untouched" caveat that described the old behavior. - **Policy `_in` is now enforced on both the row-`filter` and insert-`check` paths, closing a fail-open row-security gap** (`internal/policy/policy.go`, `internal/api/ingest.go`, `docs/src/content/docs/access-control.mdx`, plus tests in `internal/policy/policy_test.go`, `internal/api/ingest_test.go`): closes #224. The `Filter` schema accepted `_in` but the engine never read it: on the row-`filter`/SELECT path `resolveFilters` had no `_in` branch, so a row-security filter like `tenant_id: { _in: … }` produced **no `WHERE` predicate** and the role saw every row instead of its tenant subset (a fail-open, same family as #223); on the `check`/INSERT path only `_eq` was honored, silently dropping any other operator. `_in` now takes a single claim that resolves to a JSON **array** (the multi-tenant case — a token's `tenant_ids` list) and emits `col IN (?, …)` with one bound param per element; a scalar claim is a one-element set, and an empty/absent claim matches **no rows** (fail-closed) rather than widening to all of them. On the insert path an `_in` check requires the column be present and one of the set — there is no single value to auto-inject as `_eq` does, so an omitted column is rejected (`403 check failed`). The comparison operators are enforced on `filter` (`_eq`/`_neq`/`_gt`/`_lt`/`_in` all produce predicates now, so nothing is rejected there) and, on `check`, `_neq`/`_gt`/`_lt` become a loud config-load rejection (no insert-time semantics; `check` honors `_eq` + `_in`). The `_in` value stays a single templated string in the wire schema (Go `Filter.In`, SDK `PolicyFilter._in`), matching the established "set = array" shape of the caller-query `in` operator. - **`denied_aggregations` is now enforced case-insensitively against the caller-supplied function name, closing a policy bypass** (`internal/policy/policy.go`, plus tests in `internal/policy/policy_test.go`): closes #318. `IsAggregationAllowed` lower-cased the aggregation name only *after* the deny-list loop and the empty-allow-list early return, so the deny check compared a lower-cased deny entry against the raw caller input — a denied aggregation slipped past simply by changing case (`SUM` bypassed a `sum` deny entry, and with an empty allow list the call was then permitted). `isValidAggFn` already accepts any casing and the SQL builder emits the function name verbatim, so the denied aggregation actually executed. The case fold now happens once, before the deny check, so deny wins regardless of caller casing — matching the case-insensitive contract the access-control docs already specified. diff --git a/clients/ts/README.md b/clients/ts/README.md index 563c35c6..e4f9e794 100644 --- a/clients/ts/README.md +++ b/clients/ts/README.md @@ -131,7 +131,7 @@ const { data } = await wh.from('clicks').select('page').limit(10); Async request methods return a `Result` object — destructure it as `{ data, error }` — and never throw for anything the server returns. `.stream()` and `.liveQuery()` return controllers instead, reporting failures through the subscriber's `error` callback. -The SDK does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR`, and despite that error's `retryable: true` the SDK never re-dials the stream itself), `.stream()` / `.liveQuery()` in a runtime with no `EventSource`, and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call. `StreamController.connected()` also rejects — when the stream is already closed, closes before connecting, or doesn't reach `live` within `timeoutMs` (default `5_000` ms) — so `await` it inside a `try`/`catch`. +The SDK does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR`, and despite that error's `retryable: true` the SDK never re-dials the stream itself), `.stream()` / `.liveQuery()` in a runtime with no `EventSource`, and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call. ```ts const { data, error } = await wh.from('clicks').fetch(); diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index 7248a3c5..d3cfe278 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -230,7 +230,7 @@ Any policy value may interpolate token claims with `{{ jwt. }}`: - `{{ jwt.sub }}` → the token's `sub` claim. - `{{ jwt.app_metadata.tenant_id }}` → a nested claim. -Values are always bound as SQL **parameters**, never concatenated into the query, so templating is injection-safe. If a claim path can't be resolved, the template renders as an **empty string** (rather than leaking `` into the predicate) — which, for a tenant filter, means the caller matches no tenant and sees nothing. Make sure your identity provider actually issues the claims your policy templates reference. +Values are always bound as SQL **parameters**, never concatenated into the query, so templating is injection-safe. String, number, and boolean claims render as their exact JSON text (`org-123`, `1234567890123456`, `true`) — a 64-bit numeric claim keeps every digit, so it can never collide with a neighboring ID — and that text is what gets bound on the query path and compared type-aware on the stream. A claim value WaveHouse cannot render exactly makes the predicate match **no** rows on either surface (`1 = 0` in SQL, withheld on the stream). If a claim path can't be resolved, the template renders as an **empty string** (rather than leaking `` into the predicate) — which, for a tenant filter, means the caller matches no tenant and sees nothing. Make sure your identity provider actually issues the claims your policy templates reference. ## Insert checks @@ -379,7 +379,7 @@ SSE subscribers are checked for table-level `select` permission, have denied col - **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a 64-bit ID never falsely matches a neighbor, whether it arrives string-encoded or as a bare JSON number); an unparseable or `NaN` operand withholds the row. - **`String` columns** (again under any `Nullable`/`LowCardinality` wrapping): byte comparison *is* ClickHouse's String comparison — equality and ordering are both exact. -- **`DateTime`/`DateTime64` columns** (again under any wrapping): both operands are parsed as **instants** — through the same grammar [ingest canonicalization](/api#timestamp-canonicalization) reads — and compared chronologically, so all five operators are exact and the constant's spelling doesn't need to match the event's: ingest rewrites payload values to RFC 3339 UTC before publishing, and a zone-less constant (`2026-06-21 04:00:00`, read in the column's declared zone — ClickHouse's own rule) still matches the rewritten payload denoting that instant. **Write timestamp constants zone-less like that**: it's the one spelling that also works verbatim in the query path's SQL on every ClickHouse release (releases before 26.x reject the RFC 3339 `Z` form in a `WHERE` comparison with a type error; 26.x accepts it, and Unix-seconds strings, there too — the stream accepts every grammar spelling regardless). An operand the grammar can't read — on either side — withholds the row, as does an instant outside the column type's range (which insert-time *saturation* would have moved anyway). A timestamp column whose zone can't be resolved at runtime (see the canonicalization notes) still compares zone-explicit operands but refuses zone-less ones rather than guess the zone. +- **`DateTime`/`DateTime64` columns** (again under any wrapping): both operands are parsed as **instants** — through the same grammar [ingest canonicalization](/api#timestamp-canonicalization) reads — and compared chronologically, so all five operators are exact and the constant's spelling doesn't need to match the event's: ingest rewrites payload values to RFC 3339 UTC before publishing, and a zone-less constant (`2026-06-21 04:00:00`, read in the column's declared zone, else the discovered server default — ClickHouse's own rule) still matches the rewritten payload denoting that instant. **Write timestamp constants zone-less like that**: it's the one spelling that also works verbatim in the query path's SQL on every ClickHouse release (releases before 26.x reject the RFC 3339 `Z` form in a `WHERE` comparison with a type error; 26.x accepts it, and Unix-seconds strings, there too — the stream accepts every grammar spelling regardless). An operand the grammar can't read — on either side — withholds the row, as does an instant outside the column type's range (which insert-time *saturation* would have moved anyway). A column whose **declared** zone can't be loaded at runtime has no timestamp parser at all — it falls into the byte-equality bucket below (its values aren't canonicalized at ingest either), so `_neq`/`_gt`/`_lt` withhold every row. A column relying on the **server default** zone when that couldn't be resolved keeps instant comparison for zone-explicit operands (RFC 3339, Unix seconds) but refuses zone-less ones rather than guess the zone. - **Every other type** (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, `IPv4`/`IPv6`, `FixedString`, …): only byte-equality is trusted. `_eq`/`_in` admit exactly the event's own text rendering — write the filter value the way your events carry it (`true`, not `1`; a lowercase UUID if that's what clients send; `Date` values keep the producer's spelling — unlike `DateTime`, they are not canonicalized at ingest). The same constant is bound into the query path's SQL, where ClickHouse compares it against the column's declared type rather than the event's text rendering — so pick a value that works on both surfaces, and verify the query path returns what you expect before relying on the filter. `_neq`, `_gt` and `_lt` withhold **every** row: a text difference can be pure representation (an uppercase UUID, an alternate date format, an Enum name vs. its number), so inequality and order are unprovable without ClickHouse — on these columns, use the query path for ordering/exclusion filters. - **No usable schema** — the table is unknown to schema discovery, or discovery is still failing at boot (the server serves while retrying in the background): every column is treated as the "other" bucket above (timestamp columns lose their parser too). Equality scoping keeps working; ordering and `_neq` withhold until a schema is available. diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index 01ef9171..5138bb95 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -214,7 +214,7 @@ The body is a **flat JSON object** whose keys must match column names in the tar - Type mismatches are rejected (e.g., sending a boolean for a `Float64` column). - Missing required columns (non-nullable without a default) are rejected. - Null values for non-nullable columns without a default are rejected. -- Type compatibility: `String` accepts JSON strings, numbers, and booleans (ClickHouse coerces the non-strings); `FixedString`/`UUID` accept the same at validation, but ClickHouse rejects a non-string value there, so it surfaces in the [DLQ](#dead-letter-queue-dlq); `DateTime`/`Date`/`Enum` accept JSON strings or numbers; `IPv*` accepts JSON strings (a number passes validation but ClickHouse rejects it → DLQ); `Int*`/`UInt*`/`Float*`/`Decimal` accept JSON numbers or strings — a string lets JavaScript callers avoid 64-bit precision loss, and its contents are ClickHouse's to judge (a non-numeric string is accepted here and surfaces in the DLQ, not as a `400`); `Bool` accepts JSON booleans and the numbers `0`/`1` (any other number, and *any* string — including `"true"` — passes validation but is rejected by ClickHouse → DLQ); `Array` accepts JSON arrays; `Map` accepts JSON objects; `Tuple` accepts JSON arrays or objects at validation, but ClickHouse takes an array only for an *unnamed* tuple and an object only for a *named* one — the other shape surfaces in the DLQ; any other ClickHouse type (`JSON`, `Variant`, `Dynamic`, geo, …) accepts any JSON value — WaveHouse defers to ClickHouse, so a bad value surfaces in the DLQ rather than as a `400`. +- Type compatibility: `String` accepts JSON strings, numbers, and booleans (ClickHouse coerces the non-strings); `FixedString`/`UUID` accept the same at validation, but ClickHouse rejects a non-string value there, so it surfaces in the DLQ; `DateTime`/`Date`/`Enum` accept JSON strings or numbers; `IPv*` accepts JSON strings (a number passes validation but ClickHouse rejects it → DLQ); `Int*`/`Float*`/`Decimal` accept JSON numbers or strings — a string lets JavaScript callers avoid 64-bit precision loss, and its contents are ClickHouse's to judge (a non-numeric string is accepted here and surfaces in the DLQ, not as a `400`); `Bool` accepts JSON booleans and the numbers `0`/`1` (any other number, and *any* string — including `"true"` — passes validation but is rejected by ClickHouse → DLQ); `Array` accepts JSON arrays; `Map` accepts JSON objects; `Tuple` accepts JSON arrays or objects at validation, but ClickHouse takes an array only for an *unnamed* tuple and an object only for a *named* one — the other shape surfaces in the DLQ; any other ClickHouse type (`JSON`, `Variant`, `Dynamic`, geo, …) accepts any JSON value — WaveHouse defers to ClickHouse, so a bad value surfaces in the DLQ rather than as a `400`. - `Nullable()` and `LowCardinality()` wrappers are handled transparently. - Top-level `DateTime`/`DateTime64` values are rewritten to a canonical wire form on ingest — see [Timestamp canonicalization](#timestamp-canonicalization). diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index cc96d00a..3085fcd8 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -93,7 +93,7 @@ The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https:/ ### `auth/` — Authentication -- **auth.go** — `Middleware(cfg, store, logger)`: the auth middleware. Verifies JWT tokens with HMAC **or** JWKS (never both), with the accepted `alg` pinned to the active verifier and checked before any key is consulted (rejects `alg: none` and cross-family confusion). Extracts the caller's role from a configurable dot-path claim (`auth.role_claim`, default `role`). It always runs and never rejects — a missing/invalid/expired token yields an empty role (resolved to `default_role` downstream), with the token error stashed in context so a denying gate can fail loud (`401`, not a bare `403`). Before the Bearer token it checks a non-JWT operator key (`auth.operator_key`): a constant-time match on the presented credential — an `Authorization: Operator ` header, or the `X-Operator-Key` alias — stamps the live admin role plus an operator bit (`auth.WithOperator`) that `RequireAdmin` honors even under a nil policy — a full-access break-glass credential, audit-logged at Info with no client IP (`store`/`logger` back this path). A presented-but-wrong operator key is logged at `WARN` and counted by `wavehouse_auth_operator_key_failures_total` — a probing signal on the most privileged credential — then falls through like any unauthenticated request (the middleware never rejects). +- **auth.go** — `Middleware(cfg, store, logger)`: the auth middleware. Verifies JWT tokens with HMAC **or** JWKS (never both), with the accepted `alg` pinned to the active verifier and checked before any key is consulted (rejects `alg: none` and cross-family confusion). Extracts the caller's role from a configurable dot-path claim (`auth.role_claim`, default `role`). Claims parse with `jwt.WithJSONNumber()`, so a numeric claim reaches the policy engine as its exact digits (`json.Number`, never a rounded float64) — part of the row-visibility guarantee (AGENTS.md invariant 12). It always runs and never rejects — a missing/invalid/expired token yields an empty role (resolved to `default_role` downstream), with the token error stashed in context so a denying gate can fail loud (`401`, not a bare `403`). Before the Bearer token it checks a non-JWT operator key (`auth.operator_key`): a constant-time match on the presented credential — an `Authorization: Operator ` header, or the `X-Operator-Key` alias — stamps the live admin role plus an operator bit (`auth.WithOperator`) that `RequireAdmin` honors even under a nil policy — a full-access break-glass credential, audit-logged at Info with no client IP (`store`/`logger` back this path). A presented-but-wrong operator key is logged at `WARN` and counted by `wavehouse_auth_operator_key_failures_total` — a probing signal on the most privileged credential — then falls through like any unauthenticated request (the middleware never rejects). - **context.go** — request-context accessors and their setters for the role, claims, and token error (`RoleFromContext`, `ClaimsFromContext`, `AuthErrorFromContext`, and the matching `With*` helpers). ### `cache/` — Query Cache @@ -114,7 +114,7 @@ The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https:/ ### `discovery/` — Schema Discovery & Validation - **discovery.go** — `SchemaRegistry` queries `system.columns` to discover ClickHouse table schemas. Each refresh also discovers the server's default time zone (`SELECT timezone()`) and bakes every `DateTime`/`DateTime64` column's canonicalization spec (precision + resolved zone) into the cached schema, so the per-record ingest path parses no type strings and loads no zones ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)). Supports periodic auto-refresh, on-demand refresh, and `RetryRefresh` (boot-time exponential backoff loop used by `cmd/wavehouse` so a transiently unreachable ClickHouse doesn't crash-loop the binary). Thread-safe via `sync.RWMutex`. -- **timestamp.go** — `CanonicalizeTimestamps(schema, data)` rewrites every parseable value in a top-level `DateTime`/`DateTime64` column to the canonical RFC 3339 UTC wire form before the event is published ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)): zone-less values are interpreted in the column's declared zone, else the discovered server default — ClickHouse's own rule, so the spelling changes but never the instant. Fail-open: an unparseable value or unresolvable zone passes through verbatim for ClickHouse's own parser to judge; ingest never rejects a record over its timestamp spelling. +- **timestamp.go** — `CanonicalizeTimestamps(schema, data)` rewrites every parseable value in a top-level `DateTime`/`DateTime64` column to the canonical RFC 3339 UTC wire form before the event is published ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)): zone-less values are interpreted in the column's declared zone, else the discovered server default — ClickHouse's own rule, so the spelling changes but never the instant. Fail-open: an unparseable value or unresolvable zone passes through verbatim for ClickHouse's own parser to judge; ingest never rejects a record over its timestamp spelling. `Column.TimeParser()` exposes the same grammar as a value→instant parser (nil for a column with no resolved timestamp spec — a non-timestamp column, or one whose declared zone couldn't be loaded), which the stream row-filter uses so filter constants and canonicalized payloads can't disagree on the instant ([#381](https://github.com/Wave-RF/WaveHouse/issues/381)). - **validation.go** — `Validate(schema, data)` checks incoming JSON against the discovered schema: unknown fields, type compatibility, missing required columns, null handling. Also exports the type classifiers `IsNumericType` / `IsStringType` (unwrapping `Nullable`/`LowCardinality`), which — together with `Column.TimeParser` from timestamp.go — seed the stream row-filter's `policy.ColumnSpec` comparison. - **discovery_test.go** — Unit tests for validation logic. diff --git a/docs/src/content/docs/sdk/reference.md b/docs/src/content/docs/sdk/reference.md index fe9c4150..040c4044 100644 --- a/docs/src/content/docs/sdk/reference.md +++ b/docs/src/content/docs/sdk/reference.md @@ -25,7 +25,7 @@ if (error?.code === 'ABORTED') { ## Error Handling -The SDK **never throws** for anything the server returns — all API errors come back in `Result.error`. It does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR` to the subscriber's `error` callback — see [Serving under a path prefix](/sdk#serving-under-a-path-prefix)), `.stream()` / `.liveQuery()` in a runtime with no `EventSource` (see [Runtime support](/sdk#runtime-support)), and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call, and surfaces on a stream as `SSE_CONNECT_ERROR`. [`StreamController.connected()`](/sdk/streaming#connectedtimeoutms) also rejects — when the stream is already closed, closes before connecting, or doesn't reach `live` within `timeoutMs` (default `5_000` ms) — so `await` it inside a `try`/`catch`. +The SDK **never throws** for anything the server returns — all API errors come back in `Result.error`. It does throw on caller and environment errors: a non-absolute `baseURL` (REST calls reject with a `TypeError`; streams report `SSE_CONNECT_ERROR` to the subscriber's `error` callback — see [Serving under a path prefix](/sdk#serving-under-a-path-prefix)), `.stream()` / `.liveQuery()` in a runtime with no `EventSource` (see [Runtime support](/sdk#runtime-support)), and an `auth` callback that rejects — a token-refresh failure propagates out of the REST call, and surfaces on a stream as `SSE_CONNECT_ERROR`. | Status | Code | Retryable | Description | |--------|------|-----------|-------------| @@ -87,7 +87,6 @@ createClient(config) → WaveHouseClient StreamController (NOT thenable) ├── .subscribe({ next, status?, error? }) → unsubscribe() -├── .connected(timeoutMs?) → Promise ├── .close() ├── .status → StreamStatus └── [Symbol.asyncIterator]() → AsyncIterableIterator @@ -110,7 +109,7 @@ Codegen reads `/v1/schema`, which is **admin-only**. Against a non-dev server, p | Flag | Description | Default | |------|-------------|---------| -| `--url`, `-u` | WaveHouse base URL (may include a path prefix) | `http://localhost:8080` | +| `--url`, `-u` | WaveHouse base URL | `http://localhost:8080` | | `--out`, `-o` | Output .d.ts file path | `./wavehouse.d.ts` | | `--auth`, `-a` | Bearer token (if auth required) | — | diff --git a/docs/src/content/docs/sdk/streaming.md b/docs/src/content/docs/sdk/streaming.md index 37a0cc78..a6a1ec13 100644 --- a/docs/src/content/docs/sdk/streaming.md +++ b/docs/src/content/docs/sdk/streaming.md @@ -67,17 +67,6 @@ stream.close(); Current connection status: `'connecting' | 'live' | 'reconnecting' | 'closed'`. -### `.connected(timeoutMs?)` - -Returns a promise that resolves once `.status` reaches `'live'` — the "safe to ingest now" barrier, so tests and scripts don't need sleep loops between opening a stream and inserting the rows they expect to see. Rejects if the stream is already `'closed'`, if it closes before connecting, or after `timeoutMs` milliseconds (default `5_000`) if it never reaches `'live'` — `await` it inside a `try`/`catch`. Safe to call before `.subscribe()`. - -```ts -const stream = wh.from('clicks').stream(); -const unsub = stream.subscribe({ next: (e) => console.log(e) }); -await stream.connected(); // transport is live -await wh.from('clicks').insert({ page: '/home', button: 'signup' }); // this row will arrive on the stream -``` - ### `StreamOptions` | Field | Type | Description | From 013b49dfe383a4bea8890d0b78e196dbada893fe Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 12 Aug 2026 14:49:52 -0400 Subject: [PATCH 22/27] fix(policy): fail closed on Inf filter operands, post-review docs corrections --- docs/src/content/docs/access-control.mdx | 8 ++++---- docs/src/content/docs/api.md | 2 +- docs/src/content/docs/architecture.md | 2 +- internal/policy/rowfilter.go | 8 +++++--- internal/policy/rowfilter_test.go | 19 +++++++++++++++++++ 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index d3cfe278..f36dd407 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -225,7 +225,7 @@ Multiple columns (and multiple operators on one column) are combined with `AND`. ### JWT claim templating -Any policy value may interpolate token claims with `{{ jwt. }}`: +Any `filter` or `check` value may interpolate token claims with `{{ jwt. }}` (other policy fields, like `allow_columns`, take their values literally): - `{{ jwt.sub }}` → the token's `sub` claim. - `{{ jwt.app_metadata.tenant_id }}` → a nested claim. @@ -375,11 +375,11 @@ The same policy drives every data path, but not every field is meaningful on eve | Named pipe | `GET/POST /v1/pipes/{name}` | per-pipe `allowed_roles` (not the policy engine; see [Named Pipes](/pipes)). Resource limits come from ClickHouse's [server-wide settings](/configuration#server-side-resource-limits), not per-role policy caps | :::caution[Live streams enforce column and row policy, but not resource limits] -SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Predicates are evaluated against the **full ingested event**, so a filter may key on a column the role can't `select` (denied columns are still stripped from what's delivered). The stream evaluates predicates in memory, without ClickHouse's per-type coercion, so how much it can enforce depends on what the schema says about the filtered column — and every case it cannot decide **fails closed**: ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide — with one payload-vs-stored exception, insert-time numeric narrowing, called out below. +SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Predicates are evaluated against the **full ingested event**, so a filter may key on a column the role can't `select` (denied columns are still stripped from what's delivered). The stream evaluates predicates in memory, without ClickHouse's per-type coercion, so how much it can enforce depends on what the schema says about the filtered column — and every case it cannot decide **fails closed**: for a filter constant the query path's SQL also accepts (per-type guidance below — the byte-equality bucket in particular narrows this), ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide — with one payload-vs-stored exception, insert-time numeric narrowing, called out below. - **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a 64-bit ID never falsely matches a neighbor, whether it arrives string-encoded or as a bare JSON number); an unparseable or `NaN` operand withholds the row. - **`String` columns** (again under any `Nullable`/`LowCardinality` wrapping): byte comparison *is* ClickHouse's String comparison — equality and ordering are both exact. -- **`DateTime`/`DateTime64` columns** (again under any wrapping): both operands are parsed as **instants** — through the same grammar [ingest canonicalization](/api#timestamp-canonicalization) reads — and compared chronologically, so all five operators are exact and the constant's spelling doesn't need to match the event's: ingest rewrites payload values to RFC 3339 UTC before publishing, and a zone-less constant (`2026-06-21 04:00:00`, read in the column's declared zone, else the discovered server default — ClickHouse's own rule) still matches the rewritten payload denoting that instant. **Write timestamp constants zone-less like that**: it's the one spelling that also works verbatim in the query path's SQL on every ClickHouse release (releases before 26.x reject the RFC 3339 `Z` form in a `WHERE` comparison with a type error; 26.x accepts it, and Unix-seconds strings, there too — the stream accepts every grammar spelling regardless). An operand the grammar can't read — on either side — withholds the row, as does an instant outside the column type's range (which insert-time *saturation* would have moved anyway). A column whose **declared** zone can't be loaded at runtime has no timestamp parser at all — it falls into the byte-equality bucket below (its values aren't canonicalized at ingest either), so `_neq`/`_gt`/`_lt` withhold every row. A column relying on the **server default** zone when that couldn't be resolved keeps instant comparison for zone-explicit operands (RFC 3339, Unix seconds) but refuses zone-less ones rather than guess the zone. +- **`DateTime`/`DateTime64` columns** (again under any wrapping): both operands are parsed as **instants** — through the same grammar [ingest canonicalization](/api#timestamp-canonicalization) reads — and compared chronologically, so all five operators are exact and the constant's spelling doesn't need to match the event's: ingest rewrites payload values to RFC 3339 UTC before publishing, and a zone-less constant (`2026-06-21 04:00:00`, read in the column's declared zone, else the discovered server default — ClickHouse's own rule) still matches the rewritten payload denoting that instant. **Write timestamp constants zone-less like that, or as 9–10-digit Unix-seconds strings**: those two spellings also work verbatim in the query path's SQL on every ClickHouse release, since they [parse the same under both `date_time_input_format` settings](/api#timestamp-canonicalization). Releases before 26.5 default that setting to `basic`, which rejects the RFC 3339 `Z` form in a `WHERE` comparison with a type error — and unlike ingest, the query path does not pin the setting — while 26.5+ defaults to `best_effort` and accepts it. The stream accepts every grammar spelling regardless. An operand the grammar can't read — on either side — withholds the row, as does an instant outside the column type's range (which insert-time *saturation* would have moved anyway). A column whose **declared** zone can't be loaded at runtime has no timestamp parser at all — it falls into the byte-equality bucket below (its values aren't canonicalized at ingest either), so `_neq`/`_gt`/`_lt` withhold every row. A column relying on the **server default** zone when that couldn't be resolved keeps instant comparison for zone-explicit operands (RFC 3339, Unix seconds) but refuses zone-less ones rather than guess the zone. - **Every other type** (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, `IPv4`/`IPv6`, `FixedString`, …): only byte-equality is trusted. `_eq`/`_in` admit exactly the event's own text rendering — write the filter value the way your events carry it (`true`, not `1`; a lowercase UUID if that's what clients send; `Date` values keep the producer's spelling — unlike `DateTime`, they are not canonicalized at ingest). The same constant is bound into the query path's SQL, where ClickHouse compares it against the column's declared type rather than the event's text rendering — so pick a value that works on both surfaces, and verify the query path returns what you expect before relying on the filter. `_neq`, `_gt` and `_lt` withhold **every** row: a text difference can be pure representation (an uppercase UUID, an alternate date format, an Enum name vs. its number), so inequality and order are unprovable without ClickHouse — on these columns, use the query path for ordering/exclusion filters. - **No usable schema** — the table is unknown to schema discovery, or discovery is still failing at boot (the server serves while retrying in the background): every column is treated as the "other" bucket above (timestamp columns lose their parser too). Equality scoping keeps working; ordering and `_neq` withhold until a schema is available. @@ -387,7 +387,7 @@ A few more edges worth knowing when you write a policy — the stream evaluates - **A filtered column the payload doesn't carry withholds *every* event** for that subscriber, even though the same filter matches normally on the query path. That bites a `MATERIALIZED`/`ALIAS` column (never part of an ingest payload) or a `DEFAULT` column your clients omit. The recommended [`check` + `filter` pairing](#insert-checks) is unaffected: an `_eq` insert `check` auto-injects its claim value into any payload that omits the column *before* the event is published, so the streamed event carries it and the matching row filter evaluates normally. (That holds for timestamp columns too: the injected claim value is canonicalized with the rest of the payload before publish, and the stream compares timestamps as instants, so the claim's spelling and the canonical wire spelling meet.) - **A non-scalar event value** (array/object/null) under a filtered column withholds the row. -- **Insert-time numeric narrowing bounds the fail-closed guarantee itself** — this one edge can fail *open*. The guarantee holds for the *payload* value, and the one place that differs from the stored row is a payload with more precision than the column's declared type, which the insert narrows — a `Decimal` **truncates** at its scale (`1.005`, `1.006` and `1.009` all store as `1.00` in a `Decimal(10, 2)`), while a `Float32` **rounds** to its nearest representable value (`16777217` stores as `16777216`) — so a numeric `_neq`/`_gt`/`_lt` filter on such a column compares the pre-narrowing payload, and can deliver an event whose stored row lands on the other side of the comparison: a `_gt: "1.004"` on a `Decimal(10, 2)` column delivers a payload of `1.005` on the stream, while the query path compares the stored (truncated) `1.00` and hides the row. `_eq`/`_in` cannot fail open here: ClickHouse narrows the bound filter constant to the column's declared type just as insert narrowed the payload, so an equality that matches on the stream matches the stored row too. Columns that store exactly (integer IDs, `String` tenants) are unaffected under every operator. +- **Insert-time numeric narrowing bounds the fail-closed guarantee itself** — this one edge can fail *open*. The guarantee holds for the *payload* value. The one place that differs from the stored row is a payload with more precision than the column's declared type, which the insert narrows: a `Decimal` **truncates** at its scale (`1.005`, `1.006` and `1.009` all store as `1.00` in a `Decimal(10, 2)`), while a `Float32` **rounds** to its nearest representable value (`16777217` stores as `16777216`). A numeric `_neq`/`_gt`/`_lt` filter on such a column compares the pre-narrowing payload, so it can deliver an event whose stored row lands on the other side of the comparison: a `_gt: "1.004"` on a `Decimal(10, 2)` column delivers a payload of `1.005` on the stream, while the query path compares the stored (truncated) `1.00` and hides the row. `_eq`/`_in` cannot fail open here: ClickHouse narrows the bound filter constant to the column's declared type just as insert narrowed the payload, so an equality that matches on the stream matches the stored row too. Columns that store exactly (integer IDs, `String` tenants) are unaffected under every operator. Rows withheld by row-level security are counted in `wavehouse_sse_rows_withheld_total` (labeled by table and role) — check it before concluding a quiet stream simply has no matching rows. diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index 5138bb95..02817a69 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -279,7 +279,7 @@ curl -X POST "http://localhost:8080/v1/ingest?table=clicks" \ WaveHouse pins `date_time_input_format=best_effort` on its inserts — the ClickHouse server default since 26.5. On an older server whose default was `basic`, a plain `DateTime` column read an all-digit timestamp string of five or more digits as Unix seconds (shorter runs it rejected outright, where `best_effort` reads `"2026"` as a year); under `best_effort`, `"20260711"` stores 2026-07-11, not 1970-08-23, and some lengths (e.g. 12 digits) are rejected outright. (`DateTime64` columns diverge the same way on calendar-shaped runs — `"20260711"` is 1970-08-23 under `basic`, 2026-07-11 under `best_effort` — and additionally whenever an epoch run's unit doesn't match the column scale, e.g. a 16-digit microsecond epoch into a `DateTime64(3)`; an epoch run whose unit matches the column scale (a 13-digit millisecond epoch into a `DateTime64(3)`) reads identically too — only 9–10-digit Unix-seconds runs, with an optional fraction, agree at *every* scale.) The canonical form itself is what the pin rescues: under `basic` an RFC 3339 value's `Z` suffix is rejected outright (the row fails and lands in the DLQ), and the pin is what makes it insertable regardless of server version. Zone-less date-times and 9–10-digit Unix-seconds strings parse identically under both settings. ::: -**The canonical form, precisely.** This is the one strict timestamp spelling in WaveHouse — the same one `/v1/query` and `/v1/pipes/{name}` render for top-level timestamp columns and the SSE stream carries (the raw-SQL proxy `/v1/admin/query` instead renders server-side via `date_time_output_format=iso`, which keeps trailing fraction zeros). The stream row-filter doesn't require it: row-level enforcement compares timestamp operands as **instants** under this same input grammar, so a filter constant in any accepted spelling — zone-less, RFC 3339, Unix seconds — matches the canonical payload denoting the same instant, and an operand the grammar can't read withholds the row (see [the enforcement caution](/access-control#where-each-rule-is-enforced) for per-type comparison rules and the spelling that also works in query-path SQL): +**The canonical form, precisely.** This is the one strict timestamp spelling in WaveHouse — the same one `/v1/query` and `/v1/pipes/{name}` render for top-level timestamp columns and the SSE stream carries (the raw-SQL proxy `/v1/admin/query` instead renders server-side via `date_time_output_format=iso`, which keeps trailing fraction zeros). The stream row-filter doesn't require it: row-level enforcement compares timestamp operands as **instants** under this same input grammar, so a filter constant in any accepted spelling — zone-less, RFC 3339, Unix seconds — matches the canonical payload denoting the same instant, and an operand the grammar can't read withholds the row. Instant comparison also needs the column's timestamp parser from schema discovery — with no usable schema, or a declared zone that can't be loaded at runtime, the column falls back to byte-equality, where only an exactly matching spelling admits (see [the enforcement caution](/access-control#where-each-rule-is-enforced) for per-type comparison rules and the spelling that also works in query-path SQL): - `YYYY-MM-DDTHH:MM:SSZ`, or `YYYY-MM-DDTHH:MM:SS.FZ` when there is a sub-second part: uppercase `T` separator, uppercase `Z` suffix, always UTC — never a numeric offset — and seconds always present. - The fraction is **truncated** (never rounded) to the column's precision: a `DateTime` column (whole seconds) never carries a fraction; a `DateTime64(3)` column carries at most three digits. diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index 3085fcd8..006d564b 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -76,7 +76,7 @@ The API layer uses [Chi](https://github.com/go-chi/chi) for routing with Request - **structured_query.go** — Handler for `POST /v1/query?table={table}`: validates query AST, enforces permissions, builds and executes SQL. - **ingest.go** — Accepts flat JSON body for `POST /v1/ingest?table={table}`, validates against discovered schema, optional dedup, publishes to NATS subject `ingest.{table}`. When dedup is on, a row missing the configured `id_field` can't be deduped: it is logged at `WARN` and counted by `wavehouse_ingest_dedupe_missing_id_total` (labeled by `table`), then published un-deduped — or rejected when `dedupe.require_id` is set ([#219](https://github.com/Wave-RF/WaveHouse/issues/219)). - **query.go** — Proxies raw SQL for `POST /v1/admin/query` straight to ClickHouse's HTTP interface. **Not cached** — sets `Cache-Control: no-store` so every request hits ClickHouse; DateTime is rendered ISO-8601 via `date_time_output_format=iso` (the Go-side type conversion lives in the structured-query / pipes path, not here). -- **stream.go** — Real-time streaming via SSE. Callers select a table with the `?table=` query parameter. Each connection registers one `Subscriber` (the `stream/` package) with both the event `Hub` (under its `(topic, role)`) and the shared keepalive wheel, then drains both from a single byte-pump — so idle streams keep emitting `:` keepalive comments (surviving reverse-proxy idle timeouts) while live events arrive already projected and serialized. Per-event projection/serialization happens **once per role** in the `Hub`, not once per subscriber ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)). Gap-fill replay from NATS JetStream (`DeliverByStartTime`) stays per-connection (low-volume, one-time on connect). +- **stream.go** — Real-time streaming via SSE. Callers select a table with the `?table=` query parameter. Each connection registers one `Subscriber` (the `stream/` package) with both the event `Hub` (under its `(topic, role)`) and the shared keepalive wheel, then drains both from a single byte-pump — so idle streams keep emitting `:` keepalive comments (surviving reverse-proxy idle timeouts) while live events arrive already projected and serialized. Per-event projection/serialization happens **once per role** in the `Hub`, not once per subscriber ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)); the handler also snapshots the connection's JWT claims onto the `Subscriber`, which the `Hub` evaluates per subscriber when the role carries a row-level `filter` ([#319](https://github.com/Wave-RF/WaveHouse/issues/319)). Gap-fill replay from NATS JetStream (`DeliverByStartTime`) stays per-connection (low-volume, one-time on connect). - **schema.go** — Schema discovery API: list all schemas, get one table, trigger refresh. - **dlq.go** — DLQ stats endpoint and `EnsureDLQStream` helper for creating the `WAVEHOUSE_DLQ` NATS stream. - **health.go** — Liveness (`/livez`), readiness (`/readyz`), and a content-free `Online` ping (`/v1/health`, the SDK's public liveness check); `/healthz` is a permanent alias of `/livez`, and `/health`/`/ready` are deprecated aliases. All three consult an optional `BootState` so they can return 503 while boot-time schema discovery is still failing in the retry loop (see `cmd/wavehouse/main.go`); once `BootState.Set(nil)` fires, `/livez` returns 200 and stays there. `/readyz` additionally pings ClickHouse each call; `/v1/health` deliberately does not. diff --git a/internal/policy/rowfilter.go b/internal/policy/rowfilter.go index ce4ebe2b..866b39ff 100644 --- a/internal/policy/rowfilter.go +++ b/internal/policy/rowfilter.go @@ -182,9 +182,11 @@ func compareScalar(rowVal any, filterVal string, spec ColumnSpec) (int, bool) { } a, err1 := strconv.ParseFloat(s, 64) b, err2 := strconv.ParseFloat(filterVal, 64) - // NaN must be rejected explicitly: ParseFloat accepts "NaN", and NaN's - // three-way comparison reads as "equal to everything" below — a fail-open. - if err1 != nil || err2 != nil || math.IsNaN(a) || math.IsNaN(b) { + // NaN and ±Inf must be rejected explicitly: ParseFloat accepts "NaN" and + // "Inf" spellings, NaN's three-way comparison reads as "equal to + // everything" below, and an infinite bound makes _gt/_lt admit every + // finite row — both fail-opens the query path can't reproduce. + if err1 != nil || err2 != nil || math.IsNaN(a) || math.IsNaN(b) || math.IsInf(a, 0) || math.IsInf(b, 0) { return 0, false } switch { diff --git a/internal/policy/rowfilter_test.go b/internal/policy/rowfilter_test.go index 38313961..207a5017 100644 --- a/internal/policy/rowfilter_test.go +++ b/internal/policy/rowfilter_test.go @@ -132,6 +132,25 @@ func TestRowVisible_NaN_FailsClosed(t *testing.T) { assert.False(t, neq.RowVisible(map[string]any{"amount": float64(100)}, num), "NaN is uncomparable, so even != fails closed") } +// TestRowVisible_Inf_FailsClosed: ParseFloat also accepts "Inf"/"+Inf"/"-Inf"/ +// "Infinity" (any case), and an infinite bound would make _gt/_lt admit every +// finite row — a fail-open the query path can't reproduce (ClickHouse rejects +// binding an Inf-spelled string to an integer column). Either operand parsing +// to ±Inf must withhold the row instead. +func TestRowVisible_Inf_FailsClosed(t *testing.T) { + t.Parallel() + num := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} + + byClaim := evalRowFilter(t, map[string]Filter{"amount": {Gt: new("{{ jwt.min }}")}}, map[string]any{"min": "-Inf"}) + assert.False(t, byClaim.RowVisible(map[string]any{"amount": float64(5)}, num), "-Inf lower bound must not admit finite rows") + + lt := evalRowFilter(t, map[string]Filter{"amount": {Lt: new("Infinity")}}, nil) + assert.False(t, lt.RowVisible(map[string]any{"amount": float64(5)}, num), "Infinity upper bound must not admit finite rows") + + byRow := evalRowFilter(t, map[string]Filter{"amount": {Gt: new("100")}}, nil) + assert.False(t, byRow.RowVisible(map[string]any{"amount": "+Inf"}, num), "Inf row value is uncomparable, fail closed") +} + // TestRowVisible_NumericEquality_ExactBeyondFloat64: ingest accepts string-encoded // numerics precisely so 64-bit IDs survive JS precision loss; equality must not // collapse distinct IDs that round to the same float64 (adjacent values past 2^53). From 7f66db6545ad3f2e8f5385325c84470b10877157 Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 12 Aug 2026 15:02:37 -0400 Subject: [PATCH 23/27] docs: fold Inf operands into row-filter enumeration, split guarantee sentence --- docs/src/content/docs/access-control.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index f36dd407..87f5e01e 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -375,9 +375,9 @@ The same policy drives every data path, but not every field is meaningful on eve | Named pipe | `GET/POST /v1/pipes/{name}` | per-pipe `allowed_roles` (not the policy engine; see [Named Pipes](/pipes)). Resource limits come from ClickHouse's [server-wide settings](/configuration#server-side-resource-limits), not per-role policy caps | :::caution[Live streams enforce column and row policy, but not resource limits] -SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Predicates are evaluated against the **full ingested event**, so a filter may key on a column the role can't `select` (denied columns are still stripped from what's delivered). The stream evaluates predicates in memory, without ClickHouse's per-type coercion, so how much it can enforce depends on what the schema says about the filtered column — and every case it cannot decide **fails closed**: for a filter constant the query path's SQL also accepts (per-type guidance below — the byte-equality bucket in particular narrows this), ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide — with one payload-vs-stored exception, insert-time numeric narrowing, called out below. +SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Predicates are evaluated against the **full ingested event**, so a filter may key on a column the role can't `select` (denied columns are still stripped from what's delivered). The stream evaluates predicates in memory, without ClickHouse's per-type coercion, so how much it can enforce depends on what the schema says about the filtered column — and every case it cannot decide **fails closed**. Provided the filter constant is one the query path's SQL also accepts (see the per-type guidance below — the byte-equality bucket is the one that constrains your choice of constant), ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide. The one payload-vs-stored exception, insert-time numeric narrowing, is called out below. -- **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a 64-bit ID never falsely matches a neighbor, whether it arrives string-encoded or as a bare JSON number); an unparseable or `NaN` operand withholds the row. +- **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a 64-bit ID never falsely matches a neighbor, whether it arrives string-encoded or as a bare JSON number); an unparseable, `NaN`, or infinite operand (any `Inf`/`Infinity` spelling) withholds the row. - **`String` columns** (again under any `Nullable`/`LowCardinality` wrapping): byte comparison *is* ClickHouse's String comparison — equality and ordering are both exact. - **`DateTime`/`DateTime64` columns** (again under any wrapping): both operands are parsed as **instants** — through the same grammar [ingest canonicalization](/api#timestamp-canonicalization) reads — and compared chronologically, so all five operators are exact and the constant's spelling doesn't need to match the event's: ingest rewrites payload values to RFC 3339 UTC before publishing, and a zone-less constant (`2026-06-21 04:00:00`, read in the column's declared zone, else the discovered server default — ClickHouse's own rule) still matches the rewritten payload denoting that instant. **Write timestamp constants zone-less like that, or as 9–10-digit Unix-seconds strings**: those two spellings also work verbatim in the query path's SQL on every ClickHouse release, since they [parse the same under both `date_time_input_format` settings](/api#timestamp-canonicalization). Releases before 26.5 default that setting to `basic`, which rejects the RFC 3339 `Z` form in a `WHERE` comparison with a type error — and unlike ingest, the query path does not pin the setting — while 26.5+ defaults to `best_effort` and accepts it. The stream accepts every grammar spelling regardless. An operand the grammar can't read — on either side — withholds the row, as does an instant outside the column type's range (which insert-time *saturation* would have moved anyway). A column whose **declared** zone can't be loaded at runtime has no timestamp parser at all — it falls into the byte-equality bucket below (its values aren't canonicalized at ingest either), so `_neq`/`_gt`/`_lt` withhold every row. A column relying on the **server default** zone when that couldn't be resolved keeps instant comparison for zone-explicit operands (RFC 3339, Unix seconds) but refuses zone-less ones rather than guess the zone. - **Every other type** (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, `IPv4`/`IPv6`, `FixedString`, …): only byte-equality is trusted. `_eq`/`_in` admit exactly the event's own text rendering — write the filter value the way your events carry it (`true`, not `1`; a lowercase UUID if that's what clients send; `Date` values keep the producer's spelling — unlike `DateTime`, they are not canonicalized at ingest). The same constant is bound into the query path's SQL, where ClickHouse compares it against the column's declared type rather than the event's text rendering — so pick a value that works on both surfaces, and verify the query path returns what you expect before relying on the filter. `_neq`, `_gt` and `_lt` withhold **every** row: a text difference can be pure representation (an uppercase UUID, an alternate date format, an Enum name vs. its number), so inequality and order are unprovable without ClickHouse — on these columns, use the query path for ordering/exclusion filters. From 4c434a9b4eedb3c6732e3dcbf886a43ee5bd5f06 Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 12 Aug 2026 15:12:29 -0400 Subject: [PATCH 24/27] docs: name the constant-constraining buckets in the stream guarantee --- docs/src/content/docs/access-control.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index 87f5e01e..a7b8ea84 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -375,7 +375,7 @@ The same policy drives every data path, but not every field is meaningful on eve | Named pipe | `GET/POST /v1/pipes/{name}` | per-pipe `allowed_roles` (not the policy engine; see [Named Pipes](/pipes)). Resource limits come from ClickHouse's [server-wide settings](/configuration#server-side-resource-limits), not per-role policy caps | :::caution[Live streams enforce column and row policy, but not resource limits] -SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Predicates are evaluated against the **full ingested event**, so a filter may key on a column the role can't `select` (denied columns are still stripped from what's delivered). The stream evaluates predicates in memory, without ClickHouse's per-type coercion, so how much it can enforce depends on what the schema says about the filtered column — and every case it cannot decide **fails closed**. Provided the filter constant is one the query path's SQL also accepts (see the per-type guidance below — the byte-equality bucket is the one that constrains your choice of constant), ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide. The one payload-vs-stored exception, insert-time numeric narrowing, is called out below. +SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Predicates are evaluated against the **full ingested event**, so a filter may key on a column the role can't `select` (denied columns are still stripped from what's delivered). The stream evaluates predicates in memory, without ClickHouse's per-type coercion, so how much it can enforce depends on what the schema says about the filtered column — and every case it cannot decide **fails closed**. Provided the filter constant is one the query path's SQL also accepts (see the per-type guidance below — the *every other type* bucket constrains your constant to the event's own text rendering, and on ClickHouse releases before 26.5 timestamp constants must be zone-less or Unix-seconds strings), ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide. The one payload-vs-stored exception, insert-time numeric narrowing, is called out below. - **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a 64-bit ID never falsely matches a neighbor, whether it arrives string-encoded or as a bare JSON number); an unparseable, `NaN`, or infinite operand (any `Inf`/`Infinity` spelling) withholds the row. - **`String` columns** (again under any `Nullable`/`LowCardinality` wrapping): byte comparison *is* ClickHouse's String comparison — equality and ordering are both exact. From 92774f853dfdb8cd799235a595b8177f1f11a1e2 Mon Sep 17 00:00:00 2001 From: taitelee Date: Wed, 12 Aug 2026 15:26:05 -0400 Subject: [PATCH 25/27] docs: own paragraph for stream row-filter spelling note, per-subscriber metric wording --- docs/src/content/docs/access-control.mdx | 2 +- docs/src/content/docs/api.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index a7b8ea84..48c17621 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -389,7 +389,7 @@ A few more edges worth knowing when you write a policy — the stream evaluates - **A non-scalar event value** (array/object/null) under a filtered column withholds the row. - **Insert-time numeric narrowing bounds the fail-closed guarantee itself** — this one edge can fail *open*. The guarantee holds for the *payload* value. The one place that differs from the stored row is a payload with more precision than the column's declared type, which the insert narrows: a `Decimal` **truncates** at its scale (`1.005`, `1.006` and `1.009` all store as `1.00` in a `Decimal(10, 2)`), while a `Float32` **rounds** to its nearest representable value (`16777217` stores as `16777216`). A numeric `_neq`/`_gt`/`_lt` filter on such a column compares the pre-narrowing payload, so it can deliver an event whose stored row lands on the other side of the comparison: a `_gt: "1.004"` on a `Decimal(10, 2)` column delivers a payload of `1.005` on the stream, while the query path compares the stored (truncated) `1.00` and hides the row. `_eq`/`_in` cannot fail open here: ClickHouse narrows the bound filter constant to the column's declared type just as insert narrowed the payload, so an equality that matches on the stream matches the stored row too. Columns that store exactly (integer IDs, `String` tenants) are unaffected under every operator. -Rows withheld by row-level security are counted in `wavehouse_sse_rows_withheld_total` (labeled by table and role) — check it before concluding a quiet stream simply has no matching rows. +Each row withheld **from a subscriber** by row-level security is counted in `wavehouse_sse_rows_withheld_total` (labeled by table and role; a row withheld from three subscribers counts three times) — check it before concluding a quiet stream simply has no matching rows. The resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) remain a property of the SQL query path and are **not** applied to the live event stream; if those caps are part of a role's isolation story, don't rely on them over the stream. ::: diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index 02817a69..33aa5083 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -279,7 +279,7 @@ curl -X POST "http://localhost:8080/v1/ingest?table=clicks" \ WaveHouse pins `date_time_input_format=best_effort` on its inserts — the ClickHouse server default since 26.5. On an older server whose default was `basic`, a plain `DateTime` column read an all-digit timestamp string of five or more digits as Unix seconds (shorter runs it rejected outright, where `best_effort` reads `"2026"` as a year); under `best_effort`, `"20260711"` stores 2026-07-11, not 1970-08-23, and some lengths (e.g. 12 digits) are rejected outright. (`DateTime64` columns diverge the same way on calendar-shaped runs — `"20260711"` is 1970-08-23 under `basic`, 2026-07-11 under `best_effort` — and additionally whenever an epoch run's unit doesn't match the column scale, e.g. a 16-digit microsecond epoch into a `DateTime64(3)`; an epoch run whose unit matches the column scale (a 13-digit millisecond epoch into a `DateTime64(3)`) reads identically too — only 9–10-digit Unix-seconds runs, with an optional fraction, agree at *every* scale.) The canonical form itself is what the pin rescues: under `basic` an RFC 3339 value's `Z` suffix is rejected outright (the row fails and lands in the DLQ), and the pin is what makes it insertable regardless of server version. Zone-less date-times and 9–10-digit Unix-seconds strings parse identically under both settings. ::: -**The canonical form, precisely.** This is the one strict timestamp spelling in WaveHouse — the same one `/v1/query` and `/v1/pipes/{name}` render for top-level timestamp columns and the SSE stream carries (the raw-SQL proxy `/v1/admin/query` instead renders server-side via `date_time_output_format=iso`, which keeps trailing fraction zeros). The stream row-filter doesn't require it: row-level enforcement compares timestamp operands as **instants** under this same input grammar, so a filter constant in any accepted spelling — zone-less, RFC 3339, Unix seconds — matches the canonical payload denoting the same instant, and an operand the grammar can't read withholds the row. Instant comparison also needs the column's timestamp parser from schema discovery — with no usable schema, or a declared zone that can't be loaded at runtime, the column falls back to byte-equality, where only an exactly matching spelling admits (see [the enforcement caution](/access-control#where-each-rule-is-enforced) for per-type comparison rules and the spelling that also works in query-path SQL): +**The canonical form, precisely.** This is the one strict timestamp spelling in WaveHouse — the same one `/v1/query` and `/v1/pipes/{name}` render for top-level timestamp columns and the SSE stream carries (the raw-SQL proxy `/v1/admin/query` instead renders server-side via `date_time_output_format=iso`, which keeps trailing fraction zeros): - `YYYY-MM-DDTHH:MM:SSZ`, or `YYYY-MM-DDTHH:MM:SS.FZ` when there is a sub-second part: uppercase `T` separator, uppercase `Z` suffix, always UTC — never a numeric offset — and seconds always present. - The fraction is **truncated** (never rounded) to the column's precision: a `DateTime` column (whole seconds) never carries a fraction; a `DateTime64(3)` column carries at most three digits. @@ -288,6 +288,8 @@ WaveHouse pins `date_time_input_format=best_effort` on its inserts — the Click Examples for a `DateTime64(3, 'America/New_York')` column: `"2026-06-21 00:00:00.1239"` (zone-less, read in New York) → `"2026-06-21T04:00:00.123Z"`; `"1750478400.5"` (Unix-seconds string) → `"2025-06-21T04:00:00.5Z"`; the integer number `1750478400500` (ticks at the column's millisecond scale) → `"2025-06-21T04:00:00.5Z"`. +**The stream row-filter doesn't require this spelling.** Row-level enforcement compares timestamp operands as **instants** under the same input grammar, so a filter constant in any accepted spelling — zone-less, RFC 3339, Unix seconds — matches the canonical payload denoting the same instant, and an operand the grammar can't read withholds the row. Instant comparison also needs the column's timestamp parser from schema discovery — with no usable schema, or a declared zone that can't be loaded at runtime, the column falls back to byte-equality, where only an exactly matching spelling admits. See [the enforcement caution](/access-control#where-each-rule-is-enforced) for per-type comparison rules and the spelling that also works in query-path SQL. + #### Batch Ingest A **JSON array** of objects (`[{…}, {…}]`) or an **NDJSON** body (`Content-Type: application/x-ndjson`, one JSON object per line) ingests a batch in a single request. Each record is validated, authorized, deduplicated, and published independently, so **one malformed or rejected record never blocks the rest of the batch**. (The SDK's `insert([...])` array helper uses the NDJSON form automatically; both forms return the same response.) From 7661d37daccb550f97da125e456499ad71bbb68b Mon Sep 17 00:00:00 2001 From: taitelee Date: Thu, 13 Aug 2026 22:59:39 -0400 Subject: [PATCH 26/27] fix(policy): compare SSE row filters in the column's storage domain --- AGENTS.md | 2 +- CHANGELOG.md | 4 +- docs/src/content/docs/access-control.mdx | 10 +- docs/src/content/docs/api.md | 2 +- docs/src/content/docs/architecture.md | 4 +- go.mod | 2 +- internal/discovery/validation.go | 113 +++++ internal/discovery/validation_test.go | 41 ++ internal/policy/policy.go | 40 ++ internal/policy/policy_test.go | 30 ++ internal/policy/rowfilter.go | 412 +++++++++++++++--- internal/policy/rowfilter_test.go | 258 ++++++++++- internal/stream/hub.go | 18 +- internal/stream/hub_test.go | 30 ++ tests/e2e/sdk/streaming.test.ts | 42 ++ tests/integration/rowfilter_narrowing_test.go | 230 ++++++++++ 16 files changed, 1133 insertions(+), 105 deletions(-) create mode 100644 tests/integration/rowfilter_narrowing_test.go diff --git a/AGENTS.md b/AGENTS.md index ab8650ac..c569499a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ The invariant index — what must stay true. Full narrative and rationale live i 9. **Singleflight** — `TieredCache` coalesces concurrent misses (`x/sync/singleflight`) to prevent cache stampede. 10. **Active Sweeper** — purges NATS messages that are both ACKed (written to CH) and older than the gap window; SSE gap-fill uses `DeliverByStartTime`, no in-process ring buffer. 11. **Hasura-style access control: fail-closed (security)** — `policy.IsAdmin` (role == `admin_role`, **exact case-sensitive**, default `"admin"`) is the single admin check, shared by `Evaluate`/`ResolveRole`/`Validate`/the `/v1/admin` gate/`RoleAllowed`. Empty/absent role matches nothing (no `"*"` wildcard); `Validate` rejects empty role keys; a `nil` policy (deleted) denies **everyone incl. admin** via a role — a total lockout for token-based callers, so bootstrap from the policy file, never an implicit admin grant (**exception:** the operator key's `auth.IsOperator` bit passes the `/v1/admin` gate even under a `nil` policy — a deliberate break-glass restore over HTTP, see #7). `default_role` is the one sanctioned roleless exception (`ResolveRole` maps empty → it pre-eval); `default_role == admin_role` is permitted but dev-only and loudly warned (`policy.DefaultRoleGrantsAdmin`). Preserve when touching `internal/policy` (policy twin of #13; see #159). Detail: architecture.md § `policy/`. -12. **Structured queries: column authz fail-closed (security)** — `POST /v1/query?table={table}`: typed AST validated against schema, permission-enforced, timestamp-bucketed for cache, `DefaultMaxRows` (10,000) cap. Every column reference — projection, aggregation args, `filters`, `group_by`, `order_by`, `time_range` — is authorized inside `query.Build` (the single chokepoint that enumerates them all), so no clause can skip the role's `allow_columns`/`deny_columns` check (#223). A `select_all` read by a *column-restricted* role expands to its allowed columns via `policy.AllowedProjection`, never a bare `SELECT *`; *unrestricted*/admin roles keep `SELECT *` (`policy.RestrictsColumns` decides). Omitting `columns` selects nothing (`ErrEmptyProjection` → `200 []`); `["*"]` is the literal column `*` (schema-gated, not a wildcard); a table-granted role with no readable columns fails closed (`ErrNoReadableColumns` → `403`). Structured and live-stream (`stream.filterColumns`) reads share the one per-column decision `policy.IsColumnAllowed`, so column visibility can't drift. Row visibility has the same one-source guarantee (#319): `Evaluate` resolves a role's row-`filter` once (`resolvePredicates`), and both surfaces consume that single resolution — the query path renders it to SQL (`predicatesToSQL`), the stream evaluates it in memory per subscriber (`ResolvedPermissions.RowVisible`, whose type-aware comparison fails closed on anything it can't prove about the ingested payload — `policy.ColumnSpec`, with `DateTime`/`DateTime64` operands compared as instants through the ingest grammar (`discovery.Column.TimeParser`) and claim constants rendered canonically and digit-exact by the one shared rule `policy.CanonicalScalar` (#457 — which also refuses a float64 at/past 2^53 rather than match a neighboring ID, and whose ok=false — an absent claim, a structured value, no canonical form — makes the predicate match no rows on BOTH surfaces: `1 = 0` in SQL, every row withheld in memory); insert-time numeric narrowing is the one residual payload-vs-stored asymmetry, documented in the access-control enforcement caution) — so row visibility can't drift either. Preserve when touching `internal/query` or the structured-query handler. Detail: architecture.md § `query/`. +12. **Structured queries: column authz fail-closed (security)** — `POST /v1/query?table={table}`: typed AST validated against schema, permission-enforced, timestamp-bucketed for cache, `DefaultMaxRows` (10,000) cap. Every column reference — projection, aggregation args, `filters`, `group_by`, `order_by`, `time_range` — is authorized inside `query.Build` (the single chokepoint that enumerates them all), so no clause can skip the role's `allow_columns`/`deny_columns` check (#223). A `select_all` read by a *column-restricted* role expands to its allowed columns via `policy.AllowedProjection`, never a bare `SELECT *`; *unrestricted*/admin roles keep `SELECT *` (`policy.RestrictsColumns` decides). Omitting `columns` selects nothing (`ErrEmptyProjection` → `200 []`); `["*"]` is the literal column `*` (schema-gated, not a wildcard); a table-granted role with no readable columns fails closed (`ErrNoReadableColumns` → `403`). Structured and live-stream (`stream.filterColumns`) reads share the one per-column decision `policy.IsColumnAllowed`, so column visibility can't drift. Row visibility has the same one-source guarantee (#319): `Evaluate` resolves a role's row-`filter` once (`resolvePredicates`), and both surfaces consume that single resolution — the query path renders it to SQL (`predicatesToSQL`), the stream evaluates it in memory per subscriber (`ResolvedPermissions.RowVisible`, whose type-aware comparison fails closed on anything it can't prove about the ingested payload — `policy.ColumnSpec`, with `DateTime`/`DateTime64` operands compared as instants through the ingest grammar (`discovery.Column.TimeParser`) and claim constants rendered canonically and digit-exact by the one shared rule `policy.CanonicalScalar` (#457 — which also refuses a float64 at/past 2^53 rather than match a neighboring ID, and whose ok=false — an absent claim, a structured value, no canonical form — makes the predicate match no rows on BOTH surfaces: `1 = 0` in SQL, every row withheld in memory); numeric comparison runs in the column's STORAGE domain (`policy.NumericSpec`, classified by `discovery.NumericStorageOf` — Float width rounding, Decimal scale truncation, integer exactness, both operands narrowed as ClickHouse narrows stored value and bound constant, out-of-range operands refused rather than modeled; the `tests/integration` differential oracle holds in-range verdicts equal to a live ClickHouse's and the never-admit-where-SQL-hides direction for the refused out-of-range ones); an event whose insert later fails into the DLQ is the one residual payload-vs-stored asymmetry, documented in the access-control enforcement caution) — so row visibility can't drift either. Preserve when touching `internal/query` or the structured-query handler. Detail: architecture.md § `query/`. 13. **Named query pipes: fail-closed (security)** — pre-defined SQL templates (Tinybird-style) with param binding + caching; `GET/POST /v1/pipes/{name}` sit outside `RequireAdmin`, so per-pipe `allowed_roles` is the *only* execute-path gate, via `policy.RoleAllowed`: exact allowlist membership (no `"*"`), admin always passes, empty/absent role and empty-string entries authorize nobody, and no `allowed_roles` → admin-only. Preserve and exercise via `testutil.RunRoleMatrix` / `StandardRoleMatrix` (see #159). Detail: architecture.md § `pipes/`. 14. **TypeScript SDK** — `@wavehouse/sdk`: zero-dep client, typed query builder, real-time SSE, live queries (incrementable/decomposable/poll aggregation), codegen CLI. The canonical client (see §SDK Sync). 15. **Observability invariants** — stdout always 100% (sampling is OTLP-push-only); WARN+ERROR always export at 100% (a non-configurable floor — don't expose it); gRPC OTel exporters dial lazily so an unreachable collector never blocks startup; the OTel Prometheus exporter uses a **private** `prometheus.Registry`. The OTLP endpoint/TLS/custom-CA/mTLS/headers are delegated to the OpenTelemetry SDK's standard `OTEL_EXPORTER_OTLP_*` env vars — `InitProvider` passes **no** endpoint/header options. Known gap, intentionally not patched in WaveHouse app code: the pinned gRPC logs exporter (`otlploggrpc` v0.19/v0.20) ignores the env TLS-cert vars, so a custom/private CA and mutual TLS apply to traces/metrics but **not** the logs signal (public-CA/system-roots TLS and plaintext still work for logs) — upstream bug open-telemetry/opentelemetry-go#6661. A malformed `OTEL_EXPORTER_OTLP_HEADERS` is logged and skipped by the SDK (fail-soft), not fatal. Preserve when touching the logger/sampler/provider. Detail: architecture.md § `observability/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index dad1c82e..cc99292b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,11 +39,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Dependabot npm updates moved to the pnpm workspace root, fixing recurring `ERR_PNPM_OUTDATED_LOCKFILE` CI failures on dependency PRs** (`.github/dependabot.yml`, `docs/src/content/docs/development.md`, `SECURITY.md`): the three per-member npm update configs (`directory: /docs`, `/clients/ts`, `/tests/e2e/sdk`) are replaced by a single config at the workspace root (`directory: /`, group `npm-deps`, prefix `deps`). The repo has one root `pnpm-lock.yaml` (the #190 consolidation), and Dependabot only updates a lockfile co-located with the manifest it targets — so a per-member config bumped a member's `package.json` without regenerating the root lockfile, and every such PR (e.g. #211, #337) then failed CI's `pnpm install --frozen-lockfile` with `ERR_PNPM_OUTDATED_LOCKFILE`; no rebase could fix it, because a rebase never regenerates the lockfile. Pointing at the root lets Dependabot read `pnpm-workspace.yaml`, walk every member, and update the one lockfile within the PR, and as a bonus brings the root `package.json`'s own devDeps (biome, markdownlint, nyc) under Dependabot. Trade-off: one combined weekly npm PR with a single `deps:` prefix instead of three per-area PRs (`docs:` / `deps(sdk):` / `deps(tests):`). +- **Go toolchain requirement bumped to 1.26.6** (`go.mod`): the `go` directive moves from `1.26.5` to `1.26.6` so local builds (via `GOTOOLCHAIN=auto`) and CI's `setup-go` (which reads `go.mod` via `go-version-file`) install a Go whose standard library clears the `govulncheck` findings GO-2026-6218 (`net/url`), GO-2026-6091 (`html/template`) and GO-2026-6090 (`crypto/tls`), all fixed in 1.26.6 — those findings were failing `make verify`'s `vulncheck` leaf (and with it the pre-commit hook) on every tree. Patch-level toolchain bump only — no source changes — and the released binaries pick up the patched stdlib too. + - **Go toolchain requirement bumped to 1.26.5** (`go.mod`): the `go` directive moves from `1.26.4` to `1.26.5` so local builds (via `GOTOOLCHAIN=auto`) and CI's `setup-go` (which reads `go.mod` via `go-version-file`) install a Go whose standard library clears the `govulncheck` findings GO-2026-5856 (`crypto/tls`) and GO-2026-4970 (`os`), both fixed in 1.26.5 — those findings were failing `make verify`'s `vulncheck` leaf (and with it the pre-commit hook) on every tree. Patch-level toolchain bump only — no source changes — and the released binaries pick up the patched stdlib too. ### Security -- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/stream/metrics.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/discovery/timestamp.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/api.md`, `docs/src/content/docs/sdk/streaming.md`, `AGENTS.md`, `SECURITY.md`, `internal/stream/doc.go`, plus tests in `internal/policy/rowfilter_test.go` (new), `internal/policy/policy_test.go`, `internal/discovery/validation_test.go`, `internal/discovery/timestamp_test.go`, `internal/stream/hub_test.go`, `tests/e2e/sdk/streaming.test.ts`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). The in-memory comparison is type-aware and **fails closed on anything it can't prove** (`policy.ColumnKind`, seeded from the schema registry): numeric columns (`Int*`/`UInt*`/`Float*`/`Decimal*`) compare numerically, matching ClickHouse — float64-equal ties resolve at arbitrary precision (`math/big`) and the hub decodes event payloads with `UseNumber` (exact digit strings, also keeping big integers byte-faithful on the SSE wire), so 64-bit IDs never falsely collide whether they arrive string-encoded (the JS-precision-loss escape hatch ingest accepts) or as bare JSON numbers, and an unparseable, `NaN`, infinite, or over-long operand withholds the row (the claim side's 100-digit bound applies to the payload operand too, so an engineered float64-tie can't turn the exact tie-break into a per-subscriber CPU sink) — `String` columns compare bytewise (exactly ClickHouse's String semantics, equality *and* ordering) — `DateTime`/`DateTime64` columns compare as **instants**, both operands read through the very grammar ingest canonicalization (#402) uses (`discovery.Column.TimeParser`: same spellings and zone rule, truncated to the column's precision, out-of-range instants that insert-time saturation would move refused), so a zone-less filter constant — the spelling query-path SQL wants — matches the canonical RFC 3339 payload the stream carries, time-window `_gt`/`_lt` policies work, and either operand the grammar can't read withholds the row — and every other type (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, IPs, `FixedString`, …) — plus every column when no schema is available (an unknown table, or the boot-time discovery-failure window where the server serves while retrying) — trusts byte-equality only: `_eq`/`_in` admit exactly the event's own text rendering, while `_neq`/`_gt`/`_lt` withhold the row, because a byte difference can be pure representation and admitting on it would deliver rows the query path excludes (an uppercase UUID under `_neq`; `"9" > "100"` as text under `_gt`). The filter *constants* are claims-derived, so the same exactness holds on that side too: the auth middleware parses JWTs with `WithJSONNumber` and every bound constant routes through the shared `policy.CanonicalScalar` rule (both landed with the claim-template entry below — before them, a Snowflake-scale `tenant` claim rendered as `"1e+16"` and the stream *delivered the float64-equal neighbor tenant's rows* while withholding the subscriber's own), and this PR extends `CanonicalScalar` in depth for claims maps that never passed through that parser (a plain `json.Unmarshal` decodes numbers as float64): a float64 at or past 2^53 is refused outright — the predicate then matches **no** rows on either surface (`1 = 0` in SQL, unconditionally false in memory), the same two-surface verdict an unresolvable claim gets — and smaller floats render positionally (`"1000000"`, never the `"1e+06"` spelling ClickHouse integer columns reject with a type error). Ambiguity therefore always costs availability (a row withheld), never confidentiality — a guarantee about the ingested payload the stream evaluates, whose one payload-vs-stored asymmetry (insert-time numeric narrowing: `Decimal` scale truncation, `Float32` rounding) is documented in the enforcement caution — and each withhold is observable via the new `wavehouse_sse_rows_withheld_total` counter (labeled by table and role, on the live and replay paths alike), separating "no matching rows" from "a fail-closed filter is withholding everything". Subscriber claims are fixed at construction (`stream.NewSubscriber(claims)` — no setter), so the fan-out's unsynchronized claims read is race-free structurally, with a dedicated `-race` test driving concurrent row-filtered broadcasts. Replay (gap-fill) applies the same per-connection row check via `Hub.ReplayProjector`, which caches the per-table column-kind lookup across the replay loop. **Perf note:** on a topic whose role carries a row-filter, part of the #294/#353 once-per-role fan-out gain is traded back for correctness — `policy.Evaluate` runs per subscriber per event there (the column projection stays shared; roles without a filter are unaffected); #435 tracks memoizing it, and `BenchmarkBroadcast_RowFilteredFanout` exists to measure it. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant originally noted in the #294/#353 Changed entry (under **Changed** in this same block, which now points back here). +- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/stream/metrics.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/discovery/timestamp.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/api.md`, `docs/src/content/docs/sdk/streaming.md`, `AGENTS.md`, `SECURITY.md`, `internal/stream/doc.go`, plus tests in `internal/policy/rowfilter_test.go` (new), `tests/integration/rowfilter_narrowing_test.go` (new), `internal/policy/policy_test.go`, `internal/discovery/validation_test.go`, `internal/discovery/timestamp_test.go`, `internal/stream/hub_test.go`, `tests/e2e/sdk/streaming.test.ts`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). The in-memory comparison is type-aware and **fails closed on anything it can't prove** (`policy.ColumnKind`, seeded from the schema registry): numeric columns (`Int*`/`UInt*`/`Float*`/`Decimal*`) compare in the column's **storage domain**, built on the same canonical machinery #457 landed for claims rather than a parallel stack: BOTH operands render to exact canonical decimal form (`CanonicalScalar`/`CanonicalNumericLiteral` — digit-bounded, so an over-long client-controlled operand is refused before any superlinear work; the hub decodes payloads with `UseNumber`, so 64-bit IDs keep exact digits whether string-encoded — the JS-precision-loss escape hatch — or bare, and big integers stay byte-faithful on the SSE wire) and a digit-string comparator (`compareCanonicalDecimals`) orders them — the former float64/`math/big` comparison stack is deleted. The operands are then narrowed the way ClickHouse narrows the stored value at insert AND the bound constant at compare (`policy.NumericSpec`, classified per column by `discovery.NumericStorageOf`): `Float32`/`Float64` round to the column's width, `Decimal` truncates at its scale, and integer columns are exact at any width, refusing fractional operands and non-plain constant spellings (`'1e3'` errors ClickHouse's integer cast per query, so the stream withholds to match; Float and Decimal casts accept every JSON-number spelling — verified — so those compare by value). Both operands are also **range-gated** per column (`Int*`/`UInt*` width bounds, the Decimal precision budget): ClickHouse's reading of an out-of-range constant was measured to vary on a single release between a query error (a negative bound on an unsigned column — the role reads no rows), a mathematical promotion (`'256'` against a `UInt8`), and a width-boundary wrap onto a *different* value than written (`'9223372036854775808'` against an `Int64` compares as −2^63, where exact-precision comparison would have admitted the −2^63 rows SQL hides under `_neq`) — so the stream refuses all of them rather than model any one behavior, an out-of-range payload was never storable regardless, and the differential oracle asserts strict parity for the error class plus the never-admit-where-SQL-hides direction for the promotion/wrap class. This closes the review-raised ordering fail-open where a pre-narrowing payload (`16777217` into a `Float32`) satisfied `_gt: "16777216"` on the stream while the stored row (`16777216`) failed it in SQL, and `tests/integration/rowfilter_narrowing_test.go` holds every stream verdict equal to a live ClickHouse's across column shapes × payloads × constants × operators — ClickHouse itself as the oracle, the same differential rigor as #402's timestamp grammar. An operand outside the JSON number grammar (`NaN`, any `Inf` spelling) or beyond the float domain's range withholds the row — `String` columns compare bytewise (exactly ClickHouse's String semantics, equality *and* ordering) — `DateTime`/`DateTime64` columns compare as **instants**, both operands read through the very grammar ingest canonicalization (#402) uses (`discovery.Column.TimeParser`: same spellings and zone rule, truncated to the column's precision, out-of-range instants that insert-time saturation would move refused), so a zone-less filter constant — the spelling query-path SQL wants — matches the canonical RFC 3339 payload the stream carries, time-window `_gt`/`_lt` policies work, and either operand the grammar can't read withholds the row — and every other type (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, IPs, `FixedString`, …) — plus every column when no schema is available (an unknown table, or the boot-time discovery-failure window where the server serves while retrying) — trusts byte-equality only: `_eq`/`_in` admit exactly the event's own text rendering, while `_neq`/`_gt`/`_lt` withhold the row, because a byte difference can be pure representation and admitting on it would deliver rows the query path excludes (an uppercase UUID under `_neq`; `"9" > "100"` as text under `_gt`). The filter *constants* are claims-derived, so the same exactness holds on that side too: the auth middleware parses JWTs with `WithJSONNumber` and every bound constant routes through the shared `policy.CanonicalScalar` rule (both landed with the claim-template entry below — before them, a Snowflake-scale `tenant` claim rendered as `"1e+16"` and the stream *delivered the float64-equal neighbor tenant's rows* while withholding the subscriber's own), and this PR extends `CanonicalScalar` in depth for claims maps that never passed through that parser (a plain `json.Unmarshal` decodes numbers as float64): a float64 at or past 2^53 is refused outright — the predicate then matches **no** rows on either surface (`1 = 0` in SQL, unconditionally false in memory), the same two-surface verdict an unresolvable claim gets — and smaller floats render positionally (`"1000000"`, never the `"1e+06"` spelling ClickHouse integer columns reject with a type error). Ambiguity therefore always costs availability (a row withheld), never confidentiality — a guarantee about the ingested payload the stream evaluates, whose one residual payload-vs-stored asymmetry — an event whose insert later fails outright (out-of-range value, batch error → DLQ) after it was already streamed — is documented in the enforcement caution — and each withhold is observable via the new `wavehouse_sse_rows_withheld_total` counter (labeled by table and role, on the live and replay paths alike), separating "no matching rows" from "a fail-closed filter is withholding everything". Subscriber claims are fixed at construction (`stream.NewSubscriber(claims)` — no setter), so the fan-out's unsynchronized claims read is race-free structurally, with a dedicated `-race` test driving concurrent row-filtered broadcasts. Replay (gap-fill) applies the same per-connection row check via `Hub.ReplayProjector`, which caches the per-table column-kind lookup across the replay loop. **Perf note:** on a topic whose role carries a row-filter, part of the #294/#353 once-per-role fan-out gain is traded back for correctness — `policy.Evaluate` runs per subscriber per event there (the column projection stays shared; roles without a filter are unaffected); #435 tracks memoizing it, and `BenchmarkBroadcast_RowFilteredFanout` exists to measure it. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant originally noted in the #294/#353 Changed entry (under **Changed** in this same block, which now points back here). - **Row-filter claim templates now fail closed on every operator when the token doesn't carry the claim** (`internal/policy/policy.go`, `internal/policy/policy_test.go`, `internal/query/builder.go`, `internal/query/builder_test.go`, `internal/api/structured_query.go`, `internal/api/structured_query_test.go`, `internal/api/ingest.go`, `internal/api/ingest_test.go`, `internal/auth/auth.go`, `internal/auth/auth_test.go`, `internal/stream/hub_test.go`, `docs/src/content/docs/{access-control.mdx,configuration.mdx,deployment.md,pipes.mdx,architecture.md,api.md}`): closes [#385](https://github.com/Wave-RF/WaveHouse/issues/385) — the remaining fail-open half of the class [#224](https://github.com/Wave-RF/WaveHouse/issues/224) opened — and [#322](https://github.com/Wave-RF/WaveHouse/issues/322). An unresolvable `{{ jwt.* }}` template in a `filter` rendered as the empty string and still bound a real predicate for `_eq`/`_neq`/`_gt`/`_lt` — so a validly-signed token missing the claim (mixed IdP audiences, service tokens) got `WHERE tenant_id = ''` (leaking every empty-valued row), and `_neq`/`_gt` on a string column (`col != ''` / `col > ''`) matched essentially all rows, erasing the restriction entirely. Only a bare-claim `_in` failed closed. Now any filter template containing an unresolvable claim path emits the same constant-false predicate `_in` already used (`1 = 0`): on the structured-query path (`POST /v1/query`) the role sees no rows, matching what `access-control.mdx` promised all along (the SSE stream applies the same fail-closed rule per subscriber — see the row-`filter` entry above ([#381](https://github.com/Wave-RF/WaveHouse/pull/381)) — and named pipes authorize by role, not row filter). A template-free literal value — including an explicit `""` — still binds exactly as written, and an `_in` template with surrounding text joins the fail-closed path too (previously it bound the partial literal). A claim that resolves to a JSON **object or array** — usually a dropped path segment, `{{ jwt.app_metadata }}` where `{{ jwt.app_metadata.tenant_id }}` was meant — now also fails closed instead of binding its `map[…]`/`[…]` stringification (the bare-claim `_in` array keeps its multi-tenant semantics, and its **elements** now follow the same rule: one object, null, or nested-array element fails the whole set closed rather than binding a `map[…]`/`` rendering no row legitimately carries), and the JWT parser now decodes numeric claims as `json.Number` (`jwt.WithJSONNumber`), so a numeric id above 2^53 binds digit-exact instead of float64-rounding to a neighboring value. Every bound value flows through one rule, `policy.CanonicalScalar`, which also renders numeric claims in **canonical decimal form** rather than the token's spelling — `1.0`/`1e3` bind as `1`/`1000`, because the literal spelling raises a per-query `TYPE_MISMATCH` against a numeric ClickHouse column. The canonical form is **exact** at every width and precision — integers via big-int, fractions and exponents by digit-string arithmetic, never a float64 round-trip, so `0.1000000000000000000001` keeps every digit and `1e-400` fails closed instead of collapsing to `0` — while a magnitude only JSON can hold (`1e400`, `1e-400`) or a literal (or exact form) past 100 digits fails closed (the exact paths are length-bounded up front — big-int cost is superlinear in digit count and the ingest check path hands it client-controlled literals, so an unbounded literal was a single-request CPU sink) — and the insert-`check` comparison canonicalizes its payload side through the same function, so a numeric insert value matches a numeric claim by value, not by spelling. One deliberate JWT-validation shift rides along with `json.Number` decoding: a literal `exp: 0`, which float64 decoding special-cased as never-expiring, now reads as the epoch, so such tokens are rejected as expired and fall back to the roleless `default_role` like any invalid token. Breaking only for deployments that relied on the fail-open: a role whose token lacks a templated claim now reads nothing instead of *more* than intended. Insert-`check` `_eq` semantics are unchanged — the template still renders (unresolvable placeholder → empty string, surrounding literal text kept) and that rendered value is auto-injected (the required-value question is [#463](https://github.com/Wave-RF/WaveHouse/issues/463)) — but a `check: _in` template with surrounding text and an unresolvable claim now resolves to the empty set (every insert to that column rejected) instead of requiring membership in the partial literal, since the `_in` resolver is shared with the filter path. This PR also closes two adjacent fail-open paths in the same class surfaced in review: a claim template whose path is outside the `{{ jwt. }}` grammar (a hyphen, or a namespaced OIDC URL) is now **rejected when the policy is written** rather than bound as literal `{{…}}` text — a read leak for `_neq`/`_lt` and silent write corruption for `check`. That boundary is asymmetric for a running deployment: a bootstrap policy file carrying such a template makes the server **refuse to start** when the store is seeded from it (a populated KV store skips the file), an admin `PUT` on `/v1/admin/policy` (or a `POST` to its `validate` sibling) returns `400`, and a policy already stored in KV is *not* re-validated when a node loads it ([#461](https://github.com/Wave-RF/WaveHouse/issues/461)) — re-`PUT` it once after upgrading. And the row-filter predicate plus the role's `max_rows` cap are now emitted by `Build` itself, as part of the WHERE/LIMIT assembly it already does (the #322 half of this PR): splicing them into rendered SQL afterward let a crafted aggregation alias or `ORDER BY` alias-reference swallow the `WHERE` splice and delete the row filter — valid SQL returning the whole table, reachable precisely when a filter failed closed — and `ApplyMaxRows`'s uppercase-then-index offset drifted on length-changing runes (a column named `ıı`), silently dropping the cap. `InjectPermissionFilters`, `ApplyMaxRows`, and `findInsertPoint` are deleted, and no interim clause-keyword alias guard ships (one existed only between commits of this PR): a keyword-bearing alias (`Total order by region`) stays a legal identifier, contained by backtick quoting. One more member of that case-folding family: the aggregation-function allowlist is now **ASCII-exact** — `strings.ToLower` folds `İ` (U+0130) to `i`, so an aggregation named `mİn` passed the allowlist and reached ClickHouse verbatim as an unknown function, a per-query `500` where the builder's `400` belongs. Canonicalization is symmetric end to end: the insert-`check` comparison runs its **required side** through `CanonicalScalar` as well as the payload side, and a `check` value with **no placeholder** — which carries no JSON type — additionally matches by its numeric reading at compare time (a static `_eq: "1.0"` accepts an inserted `1.0` and an inserted `"1.0"` alike; without that reading the canonical payload side rejects every numeric insert the check was written to allow) while still binding and auto-injecting exactly as written, so read filters never move (`_neq: "1.0"` on a `String` version column keeps excluding exactly `'1.0'`). The second reading is gated **by type** (`policy.LiteralValue`, which `Evaluate` reserves for placeholder-free values), so a claim-derived value keeps strict canonical equality — a string-typed claim of `"1e3"` never accepts an inserted `1000`. The 100-digit literal bound counts **digits, not bytes** — sign, decimal point, and exponent marker ride free, so `-1e99` and its 101-byte written-out form both bind (the exact-form gate leaves two characters of slack past the bound, so a borderline exponent spelling like `1e101` can resolve where its written-out digits could not — the literal-side gate is the stricter of the two). The exact-form bound is **stricter than the old float64 path for wide magnitudes**: values like `1e150` or `1e-150`, whose exact decimal expansions exceed 100 digits, previously resolved (rounded) and now fail closed — on a read filter the role sees no rows; on an insert `check` the claim routes to the #463 auto-inject path, where an integer or `Decimal` column coerces the stamped `''` to `0`. **Upgrade note (data migration):** a pre-upgrade build auto-injected numeric claims above 2^53 in float64-rounded form, so rows it stamped carry a neighboring value of the true id (Snowflake-scale ids, ~1.7e18, are wide enough); the exact filter binds the true value, so those rows don't become wrong after upgrading — they become silently **unreachable** through the writer's own row filter. Before relying on the new filter, reconcile such rows (e.g. `ALTER TABLE … UPDATE` the scoped column from the rounded value to the exact claim value) — rounded and exact ids differ only above 2^53. - **A `?token=` query credential is now stripped from the request URL whichever credential wins, not only when it is the one used** (`internal/auth/auth.go`, `internal/auth/auth_test.go`, `docs/src/content/docs/api.md`, `docs/src/content/docs/reverse-proxy.mdx`, `docs/src/content/docs/sdk/index.mdx`): raised by CodeRabbit on [#448](https://github.com/Wave-RF/WaveHouse/pull/448). `bearerToken` returned from the `Authorization: Bearer` branch *before* the strip, so a request presenting both credentials — which any caller can do — left the unused JWT sitting in `r.URL` for the rest of the request's life. The operator-key path had the same shape one frame up, returning before `bearerToken` ran at all, so the strip is now resolved ahead of that branch too. Not an active leak today (WaveHouse's own request logging only ever records `r.URL.Path`, and the OTel HTTP instrumentation records no query attribute), so this is defense in depth rather than a fix for an observed exposure — but it closed an inconsistency in an invariant the code already asserted on the query-only path, where any later handler or future logging change would have turned it into one. The strip now runs once, before either credential path returns; header precedence is unchanged, unrelated query parameters survive, and both cases are pinned by tests. The docs correspondingly drop the "the header path leaves the query parameter untouched" caveat that described the old behavior. - **Policy `_in` is now enforced on both the row-`filter` and insert-`check` paths, closing a fail-open row-security gap** (`internal/policy/policy.go`, `internal/api/ingest.go`, `docs/src/content/docs/access-control.mdx`, plus tests in `internal/policy/policy_test.go`, `internal/api/ingest_test.go`): closes #224. The `Filter` schema accepted `_in` but the engine never read it: on the row-`filter`/SELECT path `resolveFilters` had no `_in` branch, so a row-security filter like `tenant_id: { _in: … }` produced **no `WHERE` predicate** and the role saw every row instead of its tenant subset (a fail-open, same family as #223); on the `check`/INSERT path only `_eq` was honored, silently dropping any other operator. `_in` now takes a single claim that resolves to a JSON **array** (the multi-tenant case — a token's `tenant_ids` list) and emits `col IN (?, …)` with one bound param per element; a scalar claim is a one-element set, and an empty/absent claim matches **no rows** (fail-closed) rather than widening to all of them. On the insert path an `_in` check requires the column be present and one of the set — there is no single value to auto-inject as `_eq` does, so an omitted column is rejected (`403 check failed`). The comparison operators are enforced on `filter` (`_eq`/`_neq`/`_gt`/`_lt`/`_in` all produce predicates now, so nothing is rejected there) and, on `check`, `_neq`/`_gt`/`_lt` become a loud config-load rejection (no insert-time semantics; `check` honors `_eq` + `_in`). The `_in` value stays a single templated string in the wire schema (Go `Filter.In`, SDK `PolicyFilter._in`), matching the established "set = array" shape of the caller-query `in` operator. diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index 8e34225a..d8dd5893 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -230,7 +230,7 @@ Any `filter` or `check` operator value may interpolate token claims with `{{ jwt - `{{ jwt.sub }}` → the token's `sub` claim. - `{{ jwt.app_metadata.tenant_id }}` → a nested claim. -Values are always bound as SQL **parameters**, never concatenated into the query, so templating is injection-safe. If a claim path in a `filter` template can't be resolved (a validly-signed token that simply doesn't carry the claim), that filter **fails closed**: the predicate becomes constant-false, so on the structured-query path (`POST /v1/query`) the role sees **no rows**, and the live stream withholds every event for that subscriber — one resolution drives both read surfaces (see [where each rule is enforced](#where-each-rule-is-enforced)). This holds for every operator — `_eq`, `_neq`, `_gt`, `_lt`, and `_in` alike. The alternative, binding the empty string the template would render to, would leave a live predicate against `''`: `_eq` would match every empty-valued row, and `_neq`/`_gt` on a string column would match essentially *all* rows, erasing the restriction. A literal value with no template in it — including an explicit `""` — binds exactly as written, even when it spells a number ([insert checks](#insert-checks) additionally accept such a literal's numeric reading at compare time, since a policy literal carries no JSON type — but what binds, and what a filter matches, is always the spelling you wrote). +Values are always bound as SQL **parameters**, never concatenated into the query, so templating is injection-safe. If a claim path in a `filter` template can't be resolved (a validly-signed token that simply doesn't carry the claim), that filter **fails closed**: the predicate becomes constant-false, so on the structured-query path (`POST /v1/query`) the role sees **no rows**, and the live stream withholds every event for that subscriber — one resolution drives both read surfaces (see [where each rule is enforced](#where-each-rule-is-enforced)). This holds for every operator — `_eq`, `_neq`, `_gt`, `_lt`, and `_in` alike. The alternative, binding the empty string the template would render to, would leave a live predicate against `''`: `_eq` would match every empty-valued row, and `_neq`/`_gt` on a string column would match essentially *all* rows, erasing the restriction. A literal value with no template in it — including an explicit `""` — binds exactly as written, even when it spells a number ([insert checks](#insert-checks) additionally accept such a literal's numeric reading at compare time, since a policy literal carries no JSON type — but what *binds* is always the spelling you wrote; whether it then matches follows the column's type: a `String` or byte-equality column compares that exact text, while a numeric column reads the constant as a number on both surfaces — on a `Float` or `Decimal` column `_eq: "1.0"` admits a stored or streamed `1`, but an integer column accepts only the plain digit form: `1.0` and `1e3` alike are refused, matching the cast error the query path raises for them (see [the enforcement caution](#where-each-rule-is-enforced))). "Can't be resolved" means the claim path is **absent** from the token (or `null`) — or resolves to a JSON **object or array** rather than a scalar, which usually means a dropped path segment (`{{ jwt.app_metadata }}` where `{{ jwt.app_metadata.tenant_id }}` was meant); the one structured shape with defined semantics is the bare-claim `_in` array above. Scalar claims — strings, booleans, and numbers — resolve normally. A numeric claim binds in **canonical decimal form**, not the token's spelling: an integer id keeps every digit — up to a 100-digit bound, far past any real id — while `1.0` or `1e3` binds as `1` and `1000`, so spelling differences between issuers never change the bound value. What must fit the bound is the value's **exact decimal form**, exponent applied — roughly 100 digits (the exact-form gate allows 102 characters, whatever they are) — so `1e400` can't be resolved, but neither can `1e150` or `1e-150`, whose short spellings expand to 151- and 152-character exact forms even though a float64 could hold them; prefer issuing integer ids as integers. Type the scoped column to match: an integer column — or a `Decimal` whose scale covers the claim's fractional digits — keeps the comparison exact, while a `Float32`/`Float64` column rounds the stored value and quietly gives that exactness back (`col = '9007199254740993'` matches a stored `9007199254740992` there). A claim that is *present but empty* is a value the token vouches for: it resolves to `''` and binds normally, so `_neq` against an empty-string claim still emits `col != ''`. Make sure your identity provider actually issues the claims your policy templates reference — and omits unset claims rather than issuing them as empty strings. @@ -383,11 +383,11 @@ The same policy drives every data path, but not every field is meaningful on eve | Named pipe | `GET/POST /v1/pipes/{name}` | per-pipe `allowed_roles` (not the policy engine; see [Named Pipes](/pipes)). Resource limits come from ClickHouse's [server-wide settings](/configuration#server-side-resource-limits), not per-role policy caps | :::caution[Live streams enforce column and row policy, but not resource limits] -SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Predicates are evaluated against the **full ingested event**, so a filter may key on a column the role can't `select` (denied columns are still stripped from what's delivered). The stream evaluates predicates in memory, without ClickHouse's per-type coercion, so how much it can enforce depends on what the schema says about the filtered column — and every case it cannot decide **fails closed**. Provided the filter constant is one the query path's SQL also accepts (see the per-type guidance below — the *every other type* bucket constrains your constant to the event's own text rendering, and on ClickHouse releases before 26.5 timestamp constants must be zone-less or Unix-seconds strings), ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide. The one payload-vs-stored exception, insert-time numeric narrowing, is called out below. +SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Predicates are evaluated against the **full ingested event**, so a filter may key on a column the role can't `select` (denied columns are still stripped from what's delivered). The stream evaluates predicates in memory — reproducing ClickHouse's coercion for the types it can classify and refusing the rest — so how much it can enforce depends on what the schema says about the filtered column, and every case it cannot decide **fails closed**. Provided the filter constant is one the query path's SQL also accepts (see the per-type guidance below — the *every other type* bucket constrains your constant to the event's own text rendering, and timestamp constants should be zone-less or Unix-seconds strings — the two spellings every ClickHouse release converts in a `WHERE` comparison), ambiguity only ever *withholds* a row the query path would return, never delivers one it would hide. The one residual payload-vs-stored case — an event whose insert later fails outright into the DLQ — is called out below. -- **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically, matching ClickHouse (`9 < 100`). Equality ties beyond `float64`'s 2^53 resolve at full precision (a 64-bit ID never falsely matches a neighbor, whether it arrives string-encoded or as a bare JSON number); an unparseable, `NaN`, infinite (any `Inf`/`Infinity` spelling), or over-long operand — past roughly the same [100-digit bound](#jwt-claim-templating) the claim side has — withholds the row. +- **Numeric columns** (`Int*`/`UInt*`/`Float*`/`Decimal*`, unwrapping `Nullable`/`LowCardinality` in any nesting): all five operators compare numerically in the column's **storage domain**, matching ClickHouse. Both operands render to the claim side's exact [canonical decimal form](#jwt-claim-templating) — so a 64-bit ID never falsely matches a neighbor, string-encoded or bare — and are then narrowed the way ClickHouse narrows the stored value and the bound constant: `Float32`/`Float64` round to the column's width, `Decimal` truncates at its scale, and integer columns are exact at any width. An operand outside the JSON number grammar (`NaN`, any `Inf`/`Infinity` spelling, hex), past roughly the [100-digit bound](#jwt-claim-templating), or beyond the float domain's range withholds the row — as does, on an integer column, a fractional operand or a constant spelled any way but the plain form ClickHouse's integer cast accepts (`1e3` errors the query there, so the stream withholds to match). Operands outside the column's **numeric range** (a negative bound on an unsigned column, a value past the integer width, more integer digits than a Decimal's precision budget) are refused on both sides too: ClickHouse's own reading of such a constant varies by pair — an error, a mathematical promotion, or a width-boundary wrap onto a *different* value than written — so the stream withholds rather than model any one behavior, and an out-of-range payload was never storable anyway. Write bounds within the column's range. - **`String` columns** (again under any `Nullable`/`LowCardinality` wrapping): byte comparison *is* ClickHouse's String comparison — equality and ordering are both exact. -- **`DateTime`/`DateTime64` columns** (again under any wrapping): both operands are parsed as **instants** — through the same grammar [ingest canonicalization](/api#timestamp-canonicalization) reads — and compared chronologically, so all five operators are exact and the constant's spelling doesn't need to match the event's: ingest rewrites payload values to RFC 3339 UTC before publishing, and a zone-less constant (`2026-06-21 04:00:00`, read in the column's declared zone, else the discovered server default — ClickHouse's own rule) still matches the rewritten payload denoting that instant. **Write timestamp constants zone-less like that, or as 9–10-digit Unix-seconds strings**: those two spellings are converted to the column type in a `WHERE` comparison on every ClickHouse release. Releases before 26.5 instead reject the RFC 3339 `Z` form there with a type error — a property of that release's constant conversion, not of `date_time_input_format`, which governs the [ingest parser WaveHouse pins](/api#timestamp-canonicalization) rather than comparison constants, so setting `best_effort` server-side does not rescue it — while 26.5+ accepts the `Z` form. The stream accepts every grammar spelling regardless. An operand the grammar can't read — on either side — withholds the row, as does an instant outside the column type's range (which insert-time *saturation* would have moved anyway). A column whose **declared** zone can't be loaded at runtime has no timestamp parser at all — it falls into the byte-equality bucket below (its values aren't canonicalized at ingest either), so `_neq`/`_gt`/`_lt` withhold every row. A column relying on the **server default** zone when that couldn't be resolved keeps instant comparison for zone-explicit operands (RFC 3339, Unix seconds) but refuses zone-less ones rather than guess the zone. +- **`DateTime`/`DateTime64` columns** (again under any wrapping): both operands are parsed as **instants** — through the same grammar [ingest canonicalization](/api#timestamp-canonicalization) reads — and compared chronologically, so all five operators are exact and the constant's spelling doesn't need to match the event's: ingest rewrites payload values to RFC 3339 UTC before publishing, and a zone-less constant (`2026-06-21 04:00:00`, read in the column's declared zone, else the discovered server default — ClickHouse's own rule) still matches the rewritten payload denoting that instant. **Write timestamp constants zone-less like that, or as 9–10-digit Unix-seconds strings**: those two spellings are converted to the column type in a `WHERE` comparison on every ClickHouse release. The RFC 3339 `Z` form is instead rejected there with a type error on older releases (verified on 25.x; 26.6 accepts it — the exact release that changed isn't pinned here, so prefer the two always-safe spellings) — a property of the release's constant conversion, not of `date_time_input_format`, which governs the [ingest parser WaveHouse pins](/api#timestamp-canonicalization) rather than comparison constants, so setting `best_effort` server-side does not rescue it. The stream accepts every grammar spelling regardless. An operand the grammar can't read — on either side — withholds the row, as does an instant outside the column type's range (which insert-time *saturation* would have moved anyway). A column whose **declared** zone can't be loaded at runtime has no timestamp parser at all — it falls into the byte-equality bucket below (its values aren't canonicalized at ingest either), so `_neq`/`_gt`/`_lt` withhold every row. A column relying on the **server default** zone when that couldn't be resolved keeps instant comparison for zone-explicit operands (RFC 3339, Unix seconds) but refuses zone-less ones rather than guess the zone. - **Every other type** (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, `IPv4`/`IPv6`, `FixedString`, …): only byte-equality is trusted. `_eq`/`_in` admit exactly the event's own text rendering — write the filter value the way your events carry it (`true`, not `1`; a lowercase UUID if that's what clients send; `Date` values keep the producer's spelling — unlike `DateTime`, they are not canonicalized at ingest). The same constant is bound into the query path's SQL, where ClickHouse compares it against the column's declared type rather than the event's text rendering — so pick a value that works on both surfaces, and verify the query path returns what you expect before relying on the filter. `_neq`, `_gt` and `_lt` withhold **every** row: a text difference can be pure representation (an uppercase UUID, an alternate date format, an Enum name vs. its number), so inequality and order are unprovable without ClickHouse — on these columns, use the query path for ordering/exclusion filters. - **No usable schema** — the table is unknown to schema discovery, or discovery is still failing at boot (the server serves while retrying in the background): every column is treated as the "other" bucket above (timestamp columns lose their parser too). Equality scoping keeps working; ordering and `_neq` withhold until a schema is available. @@ -395,7 +395,7 @@ A few more edges worth knowing when you write a policy — the stream evaluates - **A filtered column the payload doesn't carry withholds *every* event** for that subscriber, even though the same filter matches normally on the query path. That bites a `MATERIALIZED`/`ALIAS` column (never part of an ingest payload) or a `DEFAULT` column your clients omit. The recommended [`check` + `filter` pairing](#insert-checks) is unaffected: an `_eq` insert `check` auto-injects its claim value into any payload that omits the column *before* the event is published, so the streamed event carries it and the matching row filter evaluates normally. (That holds for timestamp columns too: the injected claim value is canonicalized with the rest of the payload before publish, and the stream compares timestamps as instants, so the claim's spelling and the canonical wire spelling meet.) - **A non-scalar event value** (array/object/null) under a filtered column withholds the row. -- **Insert-time numeric narrowing bounds the fail-closed guarantee itself** — this one edge can fail *open*. The guarantee holds for the *payload* value. The one place that differs from the stored row is a payload with more precision than the column's declared type, which the insert narrows: a `Decimal` **truncates** at its scale (`1.005`, `1.006` and `1.009` all store as `1.00` in a `Decimal(10, 2)`), while a `Float32` **rounds** to its nearest representable value (`16777217` stores as `16777216`). A numeric `_neq`/`_gt`/`_lt` filter on such a column compares the pre-narrowing payload, so it can deliver an event whose stored row lands on the other side of the comparison: a `_gt: "1.004"` on a `Decimal(10, 2)` column delivers a payload of `1.005` on the stream, while the query path compares the stored (truncated) `1.00` and hides the row. `_eq`/`_in` cannot fail open here: ClickHouse narrows the bound filter constant to the column's declared type just as insert narrowed the payload, so an equality that matches on the stream matches the stored row too. Columns that store exactly (integer IDs, `String` tenants) are unaffected under every operator. +- **Insert-time numeric narrowing is simulated, not skipped.** The insert narrows a payload carrying more precision than the column's declared type — a `Decimal` **truncates** at its scale (`1.005`, `1.006` and `1.009` all store as `1.00` in a `Decimal(10, 2)`), a `Float32` **rounds** to its nearest representable value (`16777217` stores as `16777216`) — and ClickHouse applies the same narrowing to a bound filter constant at compare time. The stream narrows **both operands** identically before comparing, so its verdict matches the query path's on narrowing columns under every operator: a `_gt: "1.004"` filter on a `Decimal(10, 2)` column withholds a `1.005` payload exactly as the query path hides the stored `1.00`. (An earlier revision of this feature compared the raw payload and could deliver such an event; that fail-open is closed, and an integration test holds every in-range numeric stream verdict equal to a live ClickHouse's — for the out-of-range operands the range gate refuses, it asserts the half that matters: the stream never admits a row ClickHouse hides.) What remains payload-vs-stored: an event whose insert later **fails outright** (an out-of-range value, a batch error, the DLQ) was already streamed to whichever subscribers the filter admitted, and its row never becomes queryable. One more boundary is temporal: a subscriber's claims (and role) are captured when the SSE connection is established and are never re-read, while the policy itself is re-read on every event. Tightening a policy therefore applies from the next event, but a token that expires — or claims revoked at the identity provider — keeps its open stream until the client disconnects, so treat connection lifetime as the revocation window for stream row-scoping. diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index de49c5bc..0a1ee5b9 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -581,7 +581,7 @@ Each SSE connection is bound to a single `?table=`; to consume multiple tables, Row values of top-level `DateTime`/`DateTime64` columns inside `data` arrive in the canonical RFC 3339 UTC form (ingest rewrites them before publishing — see [timestamp canonicalization](#timestamp-canonicalization)), so a live event and a `/v1/query` read of the same row agree on the instant in zone-explicit form — a zone-less spelling no longer parses as local time in a browser ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)). The two renderings are byte-identical regardless of the declared time zone or a `Nullable` wrapper — a column declared with a non-UTC zone also streams as `Z`, and `/v1/query` normalizes it (nullable or not) to UTC before rendering. Canonicalization is fail-open at ingest, so a value outside the accepted input forms streams in whatever spelling the producer sent — and for exactly those events the byte-identity above does not hold: a spelling ClickHouse accepts anyway is stored and still queries back canonical, while one it too rejects lands in the DLQ and never becomes queryable at all. Events ingested before this behavior shipped likewise replay in their original spelling. -**Note:** When access control policies are active, streamed events are filtered per the caller's role: tables without `select` permission are skipped, denied columns are removed from each event, and the role's [row-level `filter`](/access-control#row-level-security) is evaluated per subscriber against the caller's JWT claims — supplied by the connection's token (the `Authorization` header, or the `?token=` fallback above), with replayed gap-fill events filtered the same way. For a filter constant the query path's SQL also accepts ([the enforcement caution](/access-control#where-each-rule-is-enforced) gives per-type guidance), a connection is never delivered a row the query path would hide for that role — every comparison the stream can't prove fails closed and withholds the row instead. The one exception is a numeric `_neq`/`_gt`/`_lt` filter over a column type that narrows the payload on insert (a `Decimal`'s scale, `Float32` width); the caution documents that edge. The connection's claims are captured once, when the stream is established — a policy change applies from the next event, but an expired token or changed claims take effect only when the client reconnects. +**Note:** When access control policies are active, streamed events are filtered per the caller's role: tables without `select` permission are skipped, denied columns are removed from each event, and the role's [row-level `filter`](/access-control#row-level-security) is evaluated per subscriber against the caller's JWT claims — supplied by the connection's token (the `Authorization` header, or the `?token=` fallback above), with replayed gap-fill events filtered the same way. For a filter constant the query path's SQL also accepts ([the enforcement caution](/access-control#where-each-rule-is-enforced) gives per-type guidance), a connection is never delivered a row the query path would hide for that role — every comparison the stream can't prove fails closed and withholds the row instead. Numeric comparisons run in the column's storage domain — both operands narrowed the way ClickHouse narrows the stored value and the bound constant — so columns that narrow on insert (`Float32`/`Float64` width, a `Decimal`'s scale) agree with the query path too; the residual payload-vs-stored case is an event whose insert later fails into the DLQ, which the caution documents. The connection's claims are captured once, when the stream is established — a policy change applies from the next event, but an expired token or changed claims take effect only when the client reconnects. **CORS:** `/v1/stream` honors the `server.cors_allowed_origins` allowlist like every endpoint, so a browser `EventSource` from an allowed origin connects normally. `Last-Event-ID` is allow-listed in the CORS preflight so fetch-based clients can resume cross-origin. diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index 0d262b1d..b7379fdc 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -115,7 +115,7 @@ The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https:/ - **discovery.go** — `SchemaRegistry` queries `system.columns` to discover ClickHouse table schemas. Each refresh also discovers the server's default time zone (`SELECT timezone()`) and bakes every `DateTime`/`DateTime64` column's canonicalization spec (precision + resolved zone) into the cached schema, so the per-record ingest path parses no type strings and loads no zones ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)). Supports periodic auto-refresh, on-demand refresh, and `RetryRefresh` (boot-time exponential backoff loop used by `cmd/wavehouse` so a transiently unreachable ClickHouse doesn't crash-loop the binary). Thread-safe via `sync.RWMutex`. - **timestamp.go** — `CanonicalizeTimestamps(schema, data)` rewrites every parseable value in a top-level `DateTime`/`DateTime64` column to the canonical RFC 3339 UTC wire form before the event is published ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)): zone-less values are interpreted in the column's declared zone, else the discovered server default — ClickHouse's own rule, so the spelling changes but never the instant. Fail-open: an unparseable value or unresolvable zone passes through verbatim for ClickHouse's own parser to judge; ingest never rejects a record over its timestamp spelling. `Column.TimeParser()` exposes the same grammar as a value→instant parser (nil for a column with no resolved timestamp spec — a non-timestamp column, or one whose declared zone couldn't be loaded), which the stream row-filter uses so filter constants and canonicalized payloads can't disagree on the instant ([#381](https://github.com/Wave-RF/WaveHouse/issues/381)). -- **validation.go** — `Validate(schema, data)` checks incoming JSON against the discovered schema: unknown fields, type compatibility, missing required columns, null handling. Also exports the type classifiers `IsNumericType` / `IsStringType` (unwrapping `Nullable`/`LowCardinality`), which — together with `Column.TimeParser` from timestamp.go — seed the stream row-filter's `policy.ColumnSpec` comparison. +- **validation.go** — `Validate(schema, data)` checks incoming JSON against the discovered schema: unknown fields, type compatibility, missing required columns, null handling. Also exports the type classifiers `IsNumericType` / `IsStringType` and the storage-model classifier `NumericStorageOf` (all unwrapping `Nullable`/`LowCardinality`; the latter yields a numeric column's float width, `Decimal` scale, or integer exactness), which — together with `Column.TimeParser` from timestamp.go — seed the stream row-filter's `policy.ColumnSpec` comparison. - **discovery_test.go** — Unit tests for validation logic. ### `ingest/` — Ingest Pipeline, DLQ & Sweeping @@ -141,7 +141,7 @@ The package's design invariants — stdout always 100%, WARN+ERROR always export ### `policy/` — Access Control - **policy.go** — Hasura-style policy types (`Policy`, `TablePolicy`, `RolePermissions`, `Filter`), `Evaluate()` function that resolves permissions against JWT claims (including `{{ jwt.claim.path }}` template resolution), the per-column decision `IsColumnAllowed()` plus its batch/projection forms `AllowedProjection()` and `RestrictsColumns()` (used to expand a `select_all` request into a role's allowed columns), `IsAggregationAllowed()`, `Validate()`. `Evaluate` resolves a role's row-`filter` **once** (`resolvePredicates`) and feeds both read surfaces from that single resolution — `predicatesToSQL` renders the query path's `WHERE`, and the same predicates ride on `ResolvedPermissions` for the stream's in-memory check — so row visibility can't drift between them (#319). -- **rowfilter.go** — the in-memory row-visibility twin of the SQL `WHERE`: `HasRowFilter`, `RowVisible` (evaluates the resolved predicates against a decoded event, per subscriber), and `ColumnSpec` — the per-column comparison contract (`ColumnKind` `Numeric`/`Text`/`Time`/`Opaque`, plus the caller-supplied instant parser for `Time`) whose zero value is the fail-closed floor: numeric columns compare numerically with full-precision tie-breaks, `String` bytewise, `DateTime`/`DateTime64` chronologically (both operands through the ingest grammar; either side unreadable ⇒ withheld), and everything else (including any column with no usable schema) admits byte-equality only, failing `!=`/`>`/`<` closed. +- **rowfilter.go** — the in-memory row-visibility twin of the SQL `WHERE`: `HasRowFilter`, `RowVisible` (evaluates the resolved predicates against a decoded event, per subscriber), and `ColumnSpec` — the per-column comparison contract (`ColumnKind` `Numeric`/`Text`/`Time`/`Opaque`, plus each kind's parameters: the caller-supplied instant parser for `Time`, the `NumericSpec` storage model for `Numeric`) whose zero value is the fail-closed floor: numeric columns compare in the column's **storage domain** (both operands rendered to the claim side's canonical decimal form, then narrowed the way ClickHouse narrows the stored value and the bound constant — `Float32`/`Float64` width rounding, `Decimal` scale truncation, integers exact at any width, with an operand outside the column's width or a `Decimal`'s precision budget refused rather than modeled; the `tests/integration` differential oracle holds in-range verdicts equal to a live ClickHouse's and asserts the never-admit-where-SQL-hides direction for the refused out-of-range operands), `String` bytewise, `DateTime`/`DateTime64` chronologically (both operands through the ingest grammar; either side unreadable ⇒ withheld), and everything else (including any column with no usable schema) admits byte-equality only, failing `!=`/`>`/`<` closed. - **store.go** — `Store` backed by NATS KV bucket `WAVEHOUSE_POLICY`. Supports file-based bootstrap (YAML/JSON), cluster-wide sync via KV Watch, local caching. ### `pipes/` — Named Query Pipes diff --git a/go.mod b/go.mod index 0922c5d9..9cfe6851 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Wave-RF/WaveHouse -go 1.26.5 +go 1.26.6 tool ( github.com/Zxilly/go-size-analyzer/cmd/gsa diff --git a/internal/discovery/validation.go b/internal/discovery/validation.go index 67ef15a6..7a3462a1 100644 --- a/internal/discovery/validation.go +++ b/internal/discovery/validation.go @@ -3,6 +3,7 @@ package discovery import ( "encoding/json" "fmt" + "strconv" "strings" ) @@ -167,6 +168,118 @@ func IsStringType(chType string) bool { return unwrapType(chType) == "String" } +// NumericStorage describes how a ClickHouse numeric column stores a value — +// the narrowing AND range the stream row-filter must apply to BOTH comparison +// operands so its verdicts match the query path, where ClickHouse narrows the +// stored value at insert and the bound constant at compare, and errors the +// query outright on a constant outside the column's range. Each family carries +// its parameters: Integer + IntBits/Unsigned for Int*/UInt* (exact within the +// width's range), FloatBits (32/64) for Float*, Precision+Scale for Decimal*. +type NumericStorage struct { + Integer bool + IntBits int + Unsigned bool + FloatBits int + Precision int + Scale int +} + +// NumericStorageOf classifies chType's numeric storage model, unwrapping +// Nullable/LowCardinality. ok=false for non-numeric types AND for a Decimal +// whose precision/scale cannot be parsed — the caller must then refuse numeric +// comparison rather than compare under a guessed model (fail closed). +// system.columns always reports the two-argument canonical Decimal(P, S) form +// (Decimal32(4) is stored as Decimal(9, 4)); the shorthand widths are handled +// anyway for robustness, and a bare single-argument Decimal(P) is refused +// rather than misread. +func NumericStorageOf(chType string) (NumericStorage, bool) { + chType = unwrapType(chType) + switch { + case !isNumericType(chType): + return NumericStorage{}, false + case chType == "Float32": + return NumericStorage{FloatBits: 32}, true + case chType == "Float64": + return NumericStorage{FloatBits: 64}, true + case strings.HasPrefix(chType, "Decimal"): + p, s, ok := decimalParams(chType) + if !ok { + return NumericStorage{}, false + } + return NumericStorage{Precision: p, Scale: s}, true + default: // isNumericType admits only Int*/UInt* beyond the cases above + bits, unsigned, ok := integerWidth(chType) + if !ok { + return NumericStorage{}, false + } + return NumericStorage{Integer: true, IntBits: bits, Unsigned: unsigned}, true + } +} + +// integerWidth reads the bit width and signedness from an Int*/UInt* type name. +func integerWidth(chType string) (bits int, unsigned bool, ok bool) { + rest, found := strings.CutPrefix(chType, "UInt") + if found { + unsigned = true + } else { + rest, found = strings.CutPrefix(chType, "Int") + if !found { + return 0, false, false + } + } + bits, err := strconv.Atoi(rest) + if err != nil { + return 0, false, false + } + switch bits { + case 8, 16, 32, 64, 128, 256: + return bits, unsigned, true + default: + return 0, false, false + } +} + +// decimalParams extracts (P, S) from Decimal(P, S) and the DecimalN(S) +// shorthands (Decimal32/64/128/256, whose precisions are fixed at 9/18/38/76). +// ClickHouse bounds them to 1 ≤ P ≤ 76 and 0 ≤ S ≤ P; anything outside that, +// malformed, or a single-argument Decimal(P) — whose lone number is a +// precision, not a scale — reports ok=false. +func decimalParams(chType string) (precision, scale int, ok bool) { + open := strings.IndexByte(chType, '(') + if open < 0 || !strings.HasSuffix(chType, ")") { + return 0, 0, false + } + args := strings.Split(chType[open+1:len(chType)-1], ",") + last, err := strconv.Atoi(strings.TrimSpace(args[len(args)-1])) + if err != nil { + return 0, 0, false + } + switch prefix := chType[:open]; prefix { + case "Decimal32": + precision = 9 + case "Decimal64": + precision = 18 + case "Decimal128": + precision = 38 + case "Decimal256": + precision = 76 + case "Decimal": + if len(args) != 2 { + return 0, 0, false + } + if precision, err = strconv.Atoi(strings.TrimSpace(args[0])); err != nil { + return 0, 0, false + } + default: + return 0, 0, false + } + scale = last + if precision < 1 || precision > 76 || scale < 0 || scale > precision { + return 0, 0, false + } + return precision, scale, true +} + // isNumericType returns true for ClickHouse integer, float, and decimal types. func isNumericType(chType string) bool { switch { diff --git a/internal/discovery/validation_test.go b/internal/discovery/validation_test.go index 4ef2268d..a3c92691 100644 --- a/internal/discovery/validation_test.go +++ b/internal/discovery/validation_test.go @@ -284,3 +284,44 @@ func TestIsStringType(t *testing.T) { }) } } + +// TestNumericStorageOf pins the storage classification the stream row-filter +// narrows comparisons with: integer family exact at any width, float bit +// widths, Decimal scale extraction across every declaration form, wrappers +// unwrapped, and ok=false for non-numerics and for a Decimal whose scale can't +// be parsed — the caller must refuse comparison rather than guess a model. +func TestNumericStorageOf(t *testing.T) { + t.Parallel() + tests := []struct { + chType string + want NumericStorage + ok bool + }{ + {"UInt64", NumericStorage{Integer: true, IntBits: 64, Unsigned: true}, true}, + {"Int256", NumericStorage{Integer: true, IntBits: 256}, true}, + {"Nullable(UInt32)", NumericStorage{Integer: true, IntBits: 32, Unsigned: true}, true}, + {"Float32", NumericStorage{FloatBits: 32}, true}, + {"LowCardinality(Nullable(Float64))", NumericStorage{FloatBits: 64}, true}, + {"Decimal(10, 2)", NumericStorage{Precision: 10, Scale: 2}, true}, + {"Decimal(10,2)", NumericStorage{Precision: 10, Scale: 2}, true}, + {"Decimal(2, 2)", NumericStorage{Precision: 2, Scale: 2}, true}, + {"Decimal32(4)", NumericStorage{Precision: 9, Scale: 4}, true}, + {"Decimal64(0)", NumericStorage{Precision: 18, Scale: 0}, true}, + {"Decimal256(76)", NumericStorage{Precision: 76, Scale: 76}, true}, + {"Decimal", NumericStorage{}, false}, + {"Decimal(10)", NumericStorage{}, false}, + {"Decimal(10, -1)", NumericStorage{}, false}, + {"Decimal(10, 77)", NumericStorage{}, false}, + {"String", NumericStorage{}, false}, + {"DateTime", NumericStorage{}, false}, + {"Bool", NumericStorage{}, false}, + } + for _, tt := range tests { + t.Run(tt.chType, func(t *testing.T) { + t.Parallel() + got, ok := NumericStorageOf(tt.chType) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 5ad61263..9c2747ad 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -589,6 +589,46 @@ func canonicalDecimal(lit string) (string, bool) { return sign + out, true } +// compareCanonicalDecimals orders two canonical decimal forms (CanonicalScalar +// output) as numbers, by digit-string arithmetic alone — the comparison twin of +// canonicalDecimal, sharing its invariants: an optional leading '-' (never on +// zero), no leading integer zeros except a lone "0", no trailing fraction +// zeros, no exponent. Those invariants are what make the string operations +// sound: with no leading zeros a longer integer part IS the larger magnitude, +// and with no trailing zeros a fraction that is a proper prefix of another IS +// the smaller. Never a float round-trip, so 64-bit-plus IDs order exactly. +func compareCanonicalDecimals(a, b string) int { + if a == b { + return 0 + } + na, nb := strings.HasPrefix(a, "-"), strings.HasPrefix(b, "-") + switch { + case na && !nb: + return -1 + case !na && nb: + return 1 + case na && nb: + return -compareCanonicalMagnitudes(a[1:], b[1:]) + } + return compareCanonicalMagnitudes(a, b) +} + +// compareCanonicalMagnitudes orders two unsigned canonical forms. +func compareCanonicalMagnitudes(a, b string) int { + ai, af, _ := strings.Cut(a, ".") + bi, bf, _ := strings.Cut(b, ".") + if len(ai) != len(bi) { + if len(ai) < len(bi) { + return -1 + } + return 1 + } + if c := strings.Compare(ai, bi); c != 0 { + return c + } + return strings.Compare(af, bf) +} + // navigateClaims traverses nested claim maps using dot-separated path parts. func navigateClaims(claims map[string]any, parts []string) any { if len(parts) == 0 || claims == nil { diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index 9a05e173..c825040f 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -1167,6 +1167,36 @@ func TestResolveFilters_NumericClaimBinding(t *testing.T) { assert.Empty(t, params) } +// TestCompareCanonicalDecimals pins the digit-string ordering over canonical +// forms — the comparison twin of canonicalDecimal, exact at any width, never a +// float round-trip. Each pair is asserted in both directions. +func TestCompareCanonicalDecimals(t *testing.T) { + t.Parallel() + tests := []struct { + a, b string + want int + }{ + {"0", "0", 0}, + {"1", "2", -1}, + {"9", "100", -1}, + {"-1", "1", -1}, + {"-2", "-1", -1}, + {"-100", "-9", -1}, + {"1.5", "1.5", 0}, + {"1.05", "1.5", -1}, + {"0.5", "0.55", -1}, + {"2", "2.5", -1}, + {"-1.5", "-1", -1}, + {"0.0025", "0.003", -1}, + {"12345678901234567890", "12345678901234567891", -1}, + {"9007199254740992", "9007199254740993", -1}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, compareCanonicalDecimals(tt.a, tt.b), "%s vs %s", tt.a, tt.b) + assert.Equal(t, -tt.want, compareCanonicalDecimals(tt.b, tt.a), "%s vs %s reversed", tt.b, tt.a) + } +} + // TestResolveFilters_InEmptyClaim_FailsClosed: an empty set makes the predicate // match no rows (a constant-false predicate) rather than widen to all rows — the // fail-closed direction. `IN ()` is invalid SQL. Two distinct branches of diff --git a/internal/policy/rowfilter.go b/internal/policy/rowfilter.go index 0254645c..eb6c1638 100644 --- a/internal/policy/rowfilter.go +++ b/internal/policy/rowfilter.go @@ -2,7 +2,6 @@ package policy import ( "encoding/json" - "math" "math/big" "strconv" "strings" @@ -18,11 +17,221 @@ func (p *ResolvedPermissions) HasRowFilter() bool { return p != nil && len(p.rowFilter) > 0 } -// maxComparableChars bounds both operands of a numeric row-filter comparison, -// checked before any parsing. Slack past maxCanonicalDigits covers a sign, -// decimal point, and exponent on any value the claim side can bind (its exact -// form is gated at maxCanonicalDigits+2 characters). -const maxComparableChars = maxCanonicalDigits + 10 +// maxNumericOperandChars is the O(1) length pre-gate on numeric comparison +// operands, checked before ANY scan of the value. It is verdict-preserving: a +// JSON number literal carries at most four non-digit bytes (a sign, a decimal +// point, an exponent marker and its sign), so anything longer already fails +// the canonical digit bound (maxCanonicalDigits) — but proving that inside the +// canonical gate costs a full json.Valid pass plus a digit count over a +// client-controlled value, per subscriber per event on the fan-out goroutine. +// The gate refuses first, without reading the bytes. +const maxNumericOperandChars = maxCanonicalDigits + 4 + +// maxTimeOperandChars is the same O(1) pre-gate for timestamp operands: the +// ingest grammar's longest accepted spelling (RFC 3339 with nanoseconds and a +// numeric offset) is 35 bytes, so 64 is generous slack — and the parser scans +// its input, which without the gate a megabyte "timestamp" would make a +// per-subscriber-per-event cost. +const maxTimeOperandChars = 64 + +// NumericFamily classifies how a ClickHouse numeric column stores a value — +// the narrowing the row-filter comparison must apply to BOTH operands so its +// verdict matches the query path, where ClickHouse narrows the stored value at +// insert AND the filter constant at compare. The zero value is NumericNone: no +// storage model, every comparison refused — the same fail-closed zero-value +// contract as ColumnOpaque, so a future numeric type nobody classified can +// never be compared under the wrong model. +type NumericFamily uint8 + +const ( + NumericNone NumericFamily = iota // unclassified: refuse, fail closed + NumericInteger // Int*/UInt*: exact at any width + NumericFloat // Float32/Float64: IEEE rounding at Bits + NumericDecimal // Decimal*: truncation at Scale +) + +// NumericSpec is a numeric column's storage model. Bits is the bit width +// (float width for NumericFloat, integer width for NumericInteger); Unsigned +// marks UInt* (NumericInteger only); Precision and Scale are the stored total +// and fractional digit counts (NumericDecimal only, 1 ≤ Precision ≤ 76). +type NumericSpec struct { + Family NumericFamily + Bits int + Unsigned bool + Precision int + Scale int +} + +// intBounds holds each ClickHouse integer width's inclusive decimal bounds as +// canonical-form strings, computed once. A constant outside the width is not +// reliably modelable — on one and the same release, ClickHouse was measured to +// ERROR the comparison (a negative literal against an unsigned column: the +// role reads no rows), to PROMOTE and compare mathematically ('256' against a +// UInt8), and to WRAP at a width boundary ('9223372036854775808' against an +// Int64 compares as −2^63, where exact-precision comparison would ADMIT the +// −2^63 rows SQL hides under !=). Refusing out-of-range operands is the one +// rule safe under all three behaviors; the cost is availability on bounds no +// in-range data could ever satisfy differently. +var intBounds = func() map[int]struct{ sMin, sMax, uMax string } { + m := make(map[int]struct{ sMin, sMax, uMax string }, 6) + one := big.NewInt(1) + for _, bits := range []int{8, 16, 32, 64, 128, 256} { + uMax := new(big.Int).Sub(new(big.Int).Lsh(one, uint(bits)), one) + sMax := new(big.Int).Sub(new(big.Int).Lsh(one, uint(bits-1)), one) + sMin := new(big.Int).Neg(new(big.Int).Lsh(one, uint(bits-1))) + m[bits] = struct{ sMin, sMax, uMax string }{sMin.String(), sMax.String(), uMax.String()} + } + return m +}() + +// integerInRange reports whether a canonical integer form lies within the +// column's width. An unknown width refuses — fail closed, never a guessed +// range. +func (n NumericSpec) integerInRange(c string) bool { + b, ok := intBounds[n.Bits] + if !ok { + return false + } + if n.Unsigned { + return compareCanonicalDecimals(c, "0") >= 0 && compareCanonicalDecimals(c, b.uMax) <= 0 + } + return compareCanonicalDecimals(c, b.sMin) >= 0 && compareCanonicalDecimals(c, b.sMax) <= 0 +} + +// decimalInPrecision reports whether a canonical form's integer digits fit the +// column's Precision−Scale budget. A payload past it is never storable +// (DECIMAL_OVERFLOW rejects the insert); a constant past it was measured to +// promote and compare mathematically on the query path — but the integer +// widths' wrap behavior (intBounds) shows the same class is not reliably +// modelable across pairs, so the refusal keeps one rule for every family at an +// availability-only cost. A lone "0" integer part spends no digits +// (Decimal(2,2) legally stores 0.99). +func (n NumericSpec) decimalInPrecision(c string) bool { + if n.Precision < 1 || n.Scale < 0 || n.Scale > n.Precision { + // No coherent model: refuse, fail closed. The Scale bounds also protect + // truncateScale's slicing — a hand-built spec must degrade to refusal, + // never a panic on the fan-out goroutine. + return false + } + intPart, _, _ := strings.Cut(strings.TrimPrefix(c, "-"), ".") + digits := len(intPart) + if intPart == "0" { + digits = 0 + } + return digits <= n.Precision-n.Scale +} + +// compare orders two canonical decimal operands (numericCanonical / +// CanonicalNumericLiteral output) in the column's storage domain, ok=false when +// the model refuses the pair. Narrowing BOTH sides is what ClickHouse itself +// does — it narrows the payload at insert and the bound constant at compare +// (verified: Float32 stores 16777217 as 16777216 and `= '16777217'` still +// matches; Decimal(10,2) stores 1.005 as 1.00 and `= '1.005'` still matches) — +// so a threshold filter can no longer admit an event whose stored row lands on +// the other side of the comparison (the ordering fail-open raised on #381). +func (n NumericSpec) compare(a, b string) (int, bool) { + switch n.Family { + case NumericInteger: + // A fractional CONSTANT against an integer column is a per-query type + // error on the SQL path (the role reads no rows, loudly); a fractional + // PAYLOAD was never storable in the column. Refuse both — fail closed. + if strings.Contains(a, ".") || strings.Contains(b, ".") { + return 0, false + } + // Same rule for the column's range: ClickHouse's reading of an + // out-of-range constant varies by pair (error, mathematical promotion, + // or a width-boundary wrap that compares against a DIFFERENT value + // than written — see intBounds), and an out-of-range payload was never + // storable. Refuse both sides rather than model any one behavior. + if !n.integerInRange(a) || !n.integerInRange(b) { + return 0, false + } + return compareCanonicalDecimals(a, b), true + case NumericFloat: + fa, ok := narrowFloat(a, n.Bits) + if !ok { + return 0, false + } + fb, ok := narrowFloat(b, n.Bits) + if !ok { + return 0, false + } + // Both operands are now exact values of the column's float domain, so + // direct comparison IS the domain comparison — no ties left to break. + switch { + case fa < fb: + return -1, true + case fa > fb: + return 1, true + default: + return 0, true + } + case NumericDecimal: + // Precision is the range gate of the decimal family: a payload with + // integer digits past Precision−Scale is never storable (the insert is + // rejected), while a constant past it was measured to PROMOTE on the + // query path and compare mathematically — the refusal there is an + // accepted availability cost, taken because the integer widths' wrap + // behavior proves this class has no reliable single model (see + // decimalInPrecision — one story across the three sites). Scale + // truncation below cannot change integer digits, so gating + // pre-truncation is exact. + if !n.decimalInPrecision(a) || !n.decimalInPrecision(b) { + return 0, false + } + return compareCanonicalDecimals(truncateScale(a, n.Scale), truncateScale(b, n.Scale)), true + case NumericNone: + return 0, false // no storage model: refuse, fail closed + default: + return 0, false // future family nobody taught this switch: same refusal + } +} + +// narrowFloat converts a canonical decimal form to the column's float domain +// with a single correct rounding (ParseFloat at the exact bit width — never a +// float64 detour, whose double rounding can land Float32 values one ULP off). +// A magnitude the domain can't hold refuses the comparison: ClickHouse would +// store ±Inf there, and matching infinities is a verdict this evaluator can't +// prove cheaply, so the row is withheld — availability, never exposure. An +// unknown width refuses too: ParseFloat silently treats any other bitSize as +// 64, which would compare a Float32 column in the wrong (wider) domain — the +// fail-open direction — instead of the zero-value-refuses contract the +// integer and decimal families keep. +func narrowFloat(canonical string, bits int) (float64, bool) { + if bits != 32 && bits != 64 { + return 0, false + } + f, err := strconv.ParseFloat(canonical, bits) + if err != nil { + return 0, false + } + return f, true +} + +// truncateScale narrows a canonical decimal form to scale fractional digits, +// truncating toward zero — ClickHouse's Decimal cast (1.005, 1.006 and 1.009 +// all store as 1.00 in a Decimal(10,2); rounding would predict 1.01). The +// result is re-canonicalized (trailing zeros trimmed, bare "-0" folded) so it +// stays valid compareCanonicalDecimals input. +func truncateScale(canonical string, scale int) string { + intPart, frac, hasFrac := strings.Cut(canonical, ".") + if !hasFrac { + return canonical + } + if len(frac) > scale { + frac = frac[:scale] + } + for len(frac) > 0 && frac[len(frac)-1] == '0' { + frac = frac[:len(frac)-1] + } + if len(frac) > 0 { + return intPart + "." + frac + } + if intPart == "-0" { + return "0" + } + return intPart +} // ColumnKind classifies a column's ClickHouse type for the in-memory row-filter // comparison. The zero value is ColumnOpaque, so a nil map, a column absent from @@ -39,8 +248,13 @@ const ( // values, but differing strings prove nothing. So = and in admit exactly the // event's own rendering, while !=, > and < fail closed (the row is withheld). ColumnOpaque ColumnKind = iota - // ColumnNumeric (Int*/UInt*/Float*/Decimal*): operands parse as numbers and - // compare numerically, with float64-equal ties resolved at full precision. + // ColumnNumeric (Int*/UInt*/Float*/Decimal*): both operands render to exact + // canonical decimal form through the claim side's #457 machinery, then + // compare in the column's STORAGE domain (ColumnSpec.Numeric): integers at + // any width exactly, floats after IEEE narrowing to the column's bit width, + // decimals after truncation to the column's scale — the same narrowing + // ClickHouse applies to the stored value and the bound constant, so stream + // and query verdicts agree even on narrowing columns. ColumnNumeric // ColumnText (String, incl. Nullable/LowCardinality): byte comparison is // ClickHouse comparison — equality AND lexicographic order — so every operator @@ -56,10 +270,10 @@ const ( ) // ColumnSpec is one column's comparison contract for the in-memory row filter: -// the ColumnKind classification plus, for ColumnTime, the parser that maps a -// value rendering to an instant. The zero value is ColumnOpaque with no parser, -// so a nil map, an absent column, and an unknown type all land on the most -// conservative class — never a silent downgrade to a laxer comparison. +// the ColumnKind classification plus the kind's parameters — ColumnTime's +// instant parser, ColumnNumeric's storage model. The zero value is ColumnOpaque +// with neither, so a nil map, an absent column, and an unknown type all land on +// the most conservative class — never a silent downgrade to a laxer comparison. type ColumnSpec struct { Kind ColumnKind // ParseTime converts one rendering of this timestamp column's value — an @@ -71,6 +285,11 @@ type ColumnSpec struct { // the schema registry (discovery's Column.TimeParser) so the filter and // ingest canonicalization can never disagree on the grammar. ParseTime func(v any) (t time.Time, ok bool) + // Numeric is the column's storage model, set iff Kind is ColumnNumeric + // (from discovery.NumericStorageOf via the stream's columnSpecs). Its zero + // value refuses every comparison, so a ColumnNumeric spec built without a + // model fails closed rather than comparing under the wrong semantics. + Numeric NumericSpec } // RowVisible reports whether row satisfies every resolved row-filter predicate — the @@ -79,11 +298,13 @@ type ColumnSpec struct { // are ANDed; the query path joins them with AND too. // // cols maps column name → ColumnSpec, supplied by the caller from the table -// schema (see stream.Hub's columnSpecs). Numeric columns compare numerically (9 < -// 100, as ClickHouse would), String columns compare bytewise (exactly ClickHouse's -// String collation), DateTime/DateTime64 columns compare as instants (both operands -// parsed through the spec's ParseTime, the same grammar ingest canonicalizes with), -// and everything else — including every column when no schema is available — admits +// schema (see stream.Hub's columnSpecs). Numeric columns compare numerically in +// the column's storage domain (9 < 100, as ClickHouse would; Float/Decimal +// operands narrowed the way insert and constant binding narrow them), String +// columns compare bytewise (exactly ClickHouse's String collation), +// DateTime/DateTime64 columns compare as instants (both operands parsed through +// the spec's ParseTime, the same grammar ingest canonicalizes with), and +// everything else — including every column when no schema is available — admits // only byte-equality (= / in) and fails !=, > and < closed: the evaluator cannot // mirror ClickHouse's per-type coercion, and text comparison there could admit rows // the query path excludes ("9" > "100" as text, an uppercase UUID under !=). Every @@ -91,12 +312,12 @@ type ColumnSpec struct { // so the boundary costs availability, not confidentiality. // // That guarantee is about the INGESTED PAYLOAD value, which is what the stream -// evaluates; the query path evaluates the stored row. The one residual asymmetry is -// insert-time numeric narrowing — a payload carrying more precision than the -// column's declared type (a Decimal's scale, Float32 width) is rounded on insert, -// so a numeric threshold filter can admit an event whose stored row lands on the -// other side of the boundary. Documented in access-control.mdx's enforcement -// caution. +// evaluates; the query path evaluates the stored row. Storage-domain narrowing +// keeps the two verdicts aligned for values ClickHouse stores; the residual +// asymmetry is an event whose INSERT later fails entirely (out-of-range value, +// batch error → DLQ): it was already streamed to whoever the filter admitted, +// and the row never becomes queryable. Documented in access-control.mdx's +// enforcement caution. // // A nil receiver (no policy applies) makes every row visible. func (p *ResolvedPermissions) RowVisible(row map[string]any, cols map[string]ColumnSpec) bool { @@ -161,6 +382,10 @@ func (pred resolvedPredicate) matches(row map[string]any, spec ColumnSpec) bool // ok=false fails the enclosing predicate closed — for != that is what keeps a mere // representation difference (an uppercase UUID, 1 for a Bool true) from being // mistaken for a real inequality and admitting a row the query path excludes. +// filterVal is the raw resolved spelling — what byte-comparison arms compare +// and predicatesToSQL binds; the numeric arm derives its canonical reading per +// comparison, behind an O(1) length gate (see maxNumericOperandChars — folding +// the re-derivation into a memoized resolution is part of #435's scope). func compareScalar(rowVal any, filterVal string, spec ColumnSpec) (int, bool) { switch spec.Kind { case ColumnTime: @@ -172,6 +397,27 @@ func compareScalar(rowVal any, filterVal string, spec ColumnSpec) (int, bool) { if spec.ParseTime == nil { return 0, false } + // O(1) length gate before the parser scans either operand (see + // maxTimeOperandChars). The payload arrives as a string OR a + // json.Number (a Unix-epoch timestamp) — both are client-controlled and + // must be gated; a bare-number payload that skipped this would leave the + // parser's digit scan (and its %q error rendering) unbounded on the + // fan-out path. Verdict-preserving for numbers (>19 digits never parsed) + // and, for strings, refuses only past 64 bytes, where Go's parser would + // have truncated sub-second digits rather than matched a real instant. + switch v := rowVal.(type) { + case string: + if len(v) > maxTimeOperandChars { + return 0, false + } + case json.Number: + if len(v) > maxTimeOperandChars { + return 0, false + } + } + if len(filterVal) > maxTimeOperandChars { + return 0, false + } a, ok := spec.ParseTime(rowVal) if !ok { return 0, false @@ -182,43 +428,42 @@ func compareScalar(rowVal any, filterVal string, spec ColumnSpec) (int, bool) { } return a.Compare(b), true case ColumnNumeric: - s, ok := scalarString(rowVal) - if !ok { + // Both operands route through the ONE canonical numeric gate the claim + // side already uses (#457's CanonicalScalar machinery): exact decimal + // form at any width, digit-bounded (the superlinear-parse guard lives + // there — CWE-400, the row operand is client-controlled), with "NaN", + // any Inf spelling, and every non-JSON-number rendering refused by the + // grammar rather than by ad-hoc checks. Then the comparison itself runs + // in the column's storage domain (NumericSpec.compare), narrowing both + // sides the way ClickHouse narrows the stored value and the constant. + if len(filterVal) > maxNumericOperandChars { return 0, false } - // Bound both operands before any parsing: compareExact's big.Rat parse is - // superlinear in digit count and the row operand arrives client-controlled - // (ingest forwards payloads verbatim), so an over-long "number" would be a - // per-subscriber-per-event CPU sink on the fan-out goroutine — the same - // CWE-400 reason CanonicalScalar bounds the claim side. No real value is - // that wide (UInt256 is 78 digits; a resolved claim constant is already - // capped), so refuse the comparison and withhold the row. - if len(s) > maxComparableChars || len(filterVal) > maxComparableChars { + a, ok := numericCanonical(rowVal) + if !ok { return 0, false } - a, err1 := strconv.ParseFloat(s, 64) - b, err2 := strconv.ParseFloat(filterVal, 64) - // NaN and ±Inf must be rejected explicitly: ParseFloat accepts "NaN" and - // "Inf" spellings, NaN's three-way comparison reads as "equal to - // everything" below, and an infinite bound makes _gt/_lt admit every - // finite row — both fail-opens the query path can't reproduce. - if err1 != nil || err2 != nil || math.IsNaN(a) || math.IsNaN(b) || math.IsInf(a, 0) || math.IsInf(b, 0) { + b, ok := CanonicalNumericLiteral(filterVal) + if !ok { return 0, false } - switch { - case a < b: - return -1, true - case a > b: - return 1, true - default: - // Float equality is not proof of equality: distinct integers beyond - // 2^53 collapse to one float64 — whether they arrived string-encoded - // (ingest accepts that exactly to survive JS precision loss) or as bare - // JSON numbers (the stream decodes with UseNumber so s still carries - // the exact digits). Rounding is monotonic so only the equal case is - // in doubt. Resolve the tie at full precision. - return compareExact(s, filterVal) + // Spelling fidelity, integer family only: predicatesToSQL binds the + // constant AS WRITTEN, and ClickHouse's integer cast in a WHERE rejects + // non-plain spellings ("1e3", "1.5", "007") with a per-query type + // error — the role reads no rows there, so comparing the canonical + // reading here would ADMIT rows SQL never returns. A constant that + // isn't its own canonical form refuses the comparison instead + // (withhold — matching SQL's nothing, just quietly; the one measured + // over-refusal is "-0", which ClickHouse accepts in a WHERE but the + // canonical fold rewrites to "0" — availability, never exposure). + // Claim-derived constants are canonical by construction and + // unaffected. Float AND Decimal casts accept every JSON-number + // spelling ('1e3' casts to Decimal as 1000 — verified), so neither + // family gets a gate. + if spec.Numeric.Family == NumericInteger && b != filterVal { + return 0, false } + return spec.Numeric.compare(a, b) case ColumnText: s, ok := scalarString(rowVal) if !ok { @@ -243,28 +488,51 @@ func compareScalar(rowVal any, filterVal string, spec ColumnSpec) (int, bool) { } } -// compareExact compares two numeric strings at arbitrary precision — the tie-break -// for operands float64 cannot tell apart. ok=false when either side isn't an exact -// rational (±Inf, malformed), failing the predicate closed. -func compareExact(a, b string) (int, bool) { - ra, ok := new(big.Rat).SetString(a) - if !ok { - return 0, false - } - rb, ok := new(big.Rat).SetString(b) - if !ok { - return 0, false +// numericCanonical renders a payload value as the canonical decimal form the +// numeric comparison consumes, ok=false for anything that is not a number a +// ClickHouse numeric column could have stored: booleans, structured values and +// null, spellings outside the JSON number grammar ("Inf", "NaN", "0x1f", +// "007"), values whose exact digits were lost upstream (float64 at/past 2^53), +// and anything past the canonical digit bound. String and json.Number inputs +// take the claim side's own gates (CanonicalNumericLiteral / CanonicalScalar), +// so the payload and constant sides can never disagree on what counts as a +// number or how it is spelled. +func numericCanonical(v any) (string, bool) { + switch x := v.(type) { + case string: + if len(x) > maxNumericOperandChars { + return "", false + } + return CanonicalNumericLiteral(x) + case json.Number: + if len(x) > maxNumericOperandChars { + return "", false + } + return CanonicalScalar(x) + case float64: + // CanonicalScalar applies the 2^53 exactness guard and renders + // positionally; the literal gate then re-canonicalizes the one + // rendering FormatFloat emits that canonical form forbids ("-0"). + s, ok := CanonicalScalar(x) + if !ok { + return "", false + } + return CanonicalNumericLiteral(s) + default: + return "", false } - return ra.Cmp(rb), true } -// scalarString renders a JSON-decoded scalar as the canonical string compared -// against a (string-valued) filter. Non-scalars (arrays, objects, null) return -// ok=false so the predicate fails closed rather than guessing. The stream decodes -// events with UseNumber, so JSON numbers arrive as json.Number — the exact digit -// string, which is what lets compareExact distinguish 64-bit IDs that would -// collapse into one float64. The float64 case remains for callers that decoded -// without UseNumber (tests, future paths); -1 precision emits the shortest +// scalarString renders a JSON-decoded scalar as the exact BYTES compared under +// ColumnText and ColumnOpaque — deliberately the payload's raw spelling, never +// a canonical form: a String column stores the payload text verbatim, so byte +// comparison against it must use that spelling (canonicalizing "1.0" to "1" +// here would move equality away from what ClickHouse stores). Numeric coercion +// deliberately does NOT live here — the ColumnNumeric arm routes both operands +// through the claim side's canonical machinery (numericCanonical). Non-scalars +// (arrays, objects, null) return ok=false so the predicate fails closed rather +// than guessing. The float64 case serves callers that decoded without +// UseNumber (the stream itself always does); -1 precision emits the shortest // round-trip form without an exponent, so integer IDs read back as "123", not // "1.23e+02". func scalarString(v any) (string, bool) { diff --git a/internal/policy/rowfilter_test.go b/internal/policy/rowfilter_test.go index 7434f920..b913dcd3 100644 --- a/internal/policy/rowfilter_test.go +++ b/internal/policy/rowfilter_test.go @@ -9,6 +9,13 @@ import ( "github.com/stretchr/testify/assert" ) +// intNumeric is the Int64 numeric spec most tests compare under — exact within +// the width's range, the storage model of the default integer id column. Float +// and Decimal narrowing, other widths, and range gating have dedicated tests. +func intNumeric() ColumnSpec { + return ColumnSpec{Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericInteger, Bits: 64}} +} + // evalRowFilter builds a one-role/one-table policy carrying filter and returns the // permissions resolved against claims, so tests exercise the full // resolvePredicates → RowVisible path the stream fan-out uses. @@ -67,7 +74,7 @@ func TestRowVisible_Neq(t *testing.T) { "opaque column (e.g. UUID): a case difference is not proof of inequality — fail closed") num := evalRowFilter(t, map[string]Filter{"amount": {Neq: new("100")}}, nil) - kinds := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} + kinds := map[string]ColumnSpec{"amount": intNumeric()} assert.True(t, num.RowVisible(map[string]any{"amount": float64(250)}, kinds), "numeric column: 250 ≠ 100 is provable") assert.False(t, num.RowVisible(map[string]any{"amount": float64(100)}, kinds)) } @@ -85,14 +92,14 @@ func TestRowVisible_Ordering_SchemaInformed(t *testing.T) { perms := evalRowFilter(t, map[string]Filter{"amount": {Gt: new("100")}}, nil) small := map[string]any{"amount": float64(9)} big := map[string]any{"amount": float64(250)} - numeric := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} + numeric := map[string]ColumnSpec{"amount": intNumeric()} assert.False(t, perms.RowVisible(small, numeric), "numeric: 9 is not > 100") assert.True(t, perms.RowVisible(big, numeric)) assert.False(t, perms.RowVisible(small, nil), `no schema: fail closed — never the "9" > "100" text leak`) assert.False(t, perms.RowVisible(big, nil), "no schema: fail closed even when the numbers would pass") - assert.False(t, perms.RowVisible(small, map[string]ColumnSpec{"other": {Kind: ColumnNumeric}}), + assert.False(t, perms.RowVisible(small, map[string]ColumnSpec{"other": intNumeric()}), "column absent from a known schema: fail closed") assert.False(t, perms.RowVisible(small, map[string]ColumnSpec{"amount": {Kind: ColumnOpaque}}), "opaque type (Enum/Date/UUID/…): order is unprovable, fail closed") @@ -110,7 +117,7 @@ func TestRowVisible_Ordering_SchemaInformed(t *testing.T) { func TestRowVisible_NumericEquality_FloatFormatting(t *testing.T) { t.Parallel() perms := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("100")}}, nil) - num := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} + num := map[string]ColumnSpec{"amount": intNumeric()} assert.True(t, perms.RowVisible(map[string]any{"amount": float64(100)}, num)) assert.False(t, perms.RowVisible(map[string]any{"amount": float64(101)}, num)) } @@ -121,7 +128,7 @@ func TestRowVisible_NumericEquality_FloatFormatting(t *testing.T) { // NaN must withhold the row instead. func TestRowVisible_NaN_FailsClosed(t *testing.T) { t.Parallel() - num := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} + num := map[string]ColumnSpec{"amount": intNumeric()} byRow := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("100")}}, nil) assert.False(t, byRow.RowVisible(map[string]any{"amount": "NaN"}, num), "NaN row value must not equal 100") @@ -140,7 +147,7 @@ func TestRowVisible_NaN_FailsClosed(t *testing.T) { // to ±Inf must withhold the row instead. func TestRowVisible_Inf_FailsClosed(t *testing.T) { t.Parallel() - num := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} + num := map[string]ColumnSpec{"amount": intNumeric()} byClaim := evalRowFilter(t, map[string]Filter{"amount": {Gt: new("{{ jwt.min }}")}}, map[string]any{"min": "-Inf"}) assert.False(t, byClaim.RowVisible(map[string]any{"amount": float64(5)}, num), "-Inf lower bound must not admit finite rows") @@ -157,7 +164,7 @@ func TestRowVisible_Inf_FailsClosed(t *testing.T) { // collapse distinct IDs that round to the same float64 (adjacent values past 2^53). func TestRowVisible_NumericEquality_ExactBeyondFloat64(t *testing.T) { t.Parallel() - num := map[string]ColumnSpec{"id": {Kind: ColumnNumeric}} + num := map[string]ColumnSpec{"id": intNumeric()} perms := evalRowFilter(t, map[string]Filter{"id": {Eq: new("9007199254740993")}}, nil) assert.False(t, perms.RowVisible(map[string]any{"id": "9007199254740992"}, num), "float64-equal neighbors are not equal") @@ -172,21 +179,22 @@ func TestRowVisible_NumericEquality_ExactBeyondFloat64(t *testing.T) { assert.True(t, perms.RowVisible(map[string]any{"id": json.Number("9007199254740993")}, num)) neq := evalRowFilter(t, map[string]Filter{"id": {Neq: new("9007199254740993")}}, nil) - assert.True(t, neq.RowVisible(map[string]any{"id": "9007199254740992"}, num), "the exact tie-break keeps distinct IDs unequal for !=") + assert.True(t, neq.RowVisible(map[string]any{"id": "9007199254740992"}, num), "the exact comparison keeps distinct IDs unequal for !=") } -// TestRowVisible_OverlongNumericOperand_FailsClosed: the numeric arm refuses -// operands wider than maxComparableChars before parsing anything — the exact -// tie-break's big.Rat parse is superlinear in digit count and the row operand -// is client-controlled, so an over-long "number" engineered to float64-tie with -// the constant would otherwise burn CPU once per subscriber per event on the -// fan-out goroutine. Withheld, never parsed; real-width values are unaffected. +// TestRowVisible_OverlongNumericOperand_FailsClosed: an over-long operand is +// refused by the O(1) length pre-gate (maxNumericOperandChars) before ANY scan +// of its bytes — the row operand is client-controlled and the comparison runs +// per subscriber per event on the fan-out goroutine, so even a linear +// json.Valid pass over it is a cost an attacker controls. The gate is +// verdict-preserving: anything longer already fails the canonical digit bound. +// Withheld, never read; real-width values unaffected. func TestRowVisible_OverlongNumericOperand_FailsClosed(t *testing.T) { t.Parallel() - num := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} + num := map[string]ColumnSpec{"amount": intNumeric()} perms := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("100")}}, nil) - long := "100." + strings.Repeat("0", 200_000) + "1" // ties with 100 as float64 + long := "100." + strings.Repeat("0", 200_000) + "1" // far past the operand length gate assert.False(t, perms.RowVisible(map[string]any{"amount": json.Number(long)}, num)) assert.False(t, perms.RowVisible(map[string]any{"amount": long}, num), "string-encoded operand is bounded too") assert.True(t, perms.RowVisible(map[string]any{"amount": json.Number("100.0")}, num), "real-width values still compare") @@ -195,13 +203,13 @@ func TestRowVisible_OverlongNumericOperand_FailsClosed(t *testing.T) { // TestRowVisible_LossyFloatClaim_FailsClosed: a claim that arrives as a float64 // at or past 2^53 has already collapsed onto its float neighbors — rendering // digits for it could name another tenant (the fail-open caught in #381 review: -// fmt.Sprint turned the claim into "1e+16", which compareExact then matched -// against the neighbor's exact digits). The predicate must match NOTHING: not -// the neighbor the float equals, and not even the row whose exact ID the claim +// the claim rendered as "1e+16" and matched the neighbor's rows). CanonicalScalar +// now refuses such a float64 outright, so the predicate matches NOTHING: not the +// neighbor the float equals, and not even the row whose exact ID the claim // originally carried — availability, never confidentiality. func TestRowVisible_LossyFloatClaim_FailsClosed(t *testing.T) { t.Parallel() - num := map[string]ColumnSpec{"tenant_id": {Kind: ColumnNumeric}} + num := map[string]ColumnSpec{"tenant_id": intNumeric()} filter := map[string]Filter{"tenant_id": {Eq: new("{{ jwt.tenant }}")}} lossy := evalRowFilter(t, filter, map[string]any{"tenant": float64(10000000000000001)}) // already 1e16 @@ -258,6 +266,27 @@ func TestRowVisible_TimeColumn(t *testing.T) { "ColumnTime without a parser refuses the comparison — never a text fallback") } +// TestRowVisible_TimeColumn_OverlongOperand_Gated: the O(1) length gate refuses +// an over-long timestamp operand — string OR json.Number (a Unix-epoch payload +// shape) — BEFORE the parser scans it, so a client-controlled megabyte "value" +// can't stall the per-subscriber fan-out. The parser here counts every call, so +// a gated operand must produce zero calls. +func TestRowVisible_TimeColumn_OverlongOperand_Gated(t *testing.T) { + t.Parallel() + var calls int + counting := ColumnSpec{Kind: ColumnTime, ParseTime: func(v any) (time.Time, bool) { + calls++ + return time.Time{}, false + }} + cols := map[string]ColumnSpec{"created_at": counting} + perms := evalRowFilter(t, map[string]Filter{"created_at": {Eq: new("2026-06-21T04:00:00Z")}}, nil) + + huge := strings.Repeat("9", maxTimeOperandChars+1) + assert.False(t, perms.RowVisible(map[string]any{"created_at": huge}, cols)) + assert.False(t, perms.RowVisible(map[string]any{"created_at": json.Number(huge)}, cols)) + assert.Zero(t, calls, "an over-long operand must be refused before the parser is called") +} + func TestRowVisible_NilReceiver_AllVisible(t *testing.T) { t.Parallel() var perms *ResolvedPermissions @@ -282,7 +311,7 @@ func TestRowVisible_MultiplePredicates_AllMustPass(t *testing.T) { "tenant_id": {Eq: new("{{ jwt.tenant }}")}, "amount": {Gt: new("100")}, }, map[string]any{"tenant": "acme"}) - num := map[string]ColumnSpec{"amount": {Kind: ColumnNumeric}} + num := map[string]ColumnSpec{"amount": intNumeric()} assert.True(t, perms.RowVisible(map[string]any{"tenant_id": "acme", "amount": float64(250)}, num)) assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "acme", "amount": float64(9)}, num), "amount fails") assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "globex", "amount": float64(250)}, num), "tenant fails") @@ -325,3 +354,190 @@ func TestRowFilter_UnresolvableClaim_NoRowsOnBothPaths(t *testing.T) { }) } } + +// TestRowVisible_FloatNarrowing: Float32/Float64 columns compare in the +// column's float domain — BOTH operands narrowed, exactly as ClickHouse +// narrows the stored value at insert and the bound constant at compare. The +// Float32 case is the #381 review repro: payload 16777217 stores as 16777216, +// so `_gt: "16777216"` must NOT admit the event (the SQL predicate over the +// stored row is false), while equality against the same spelling matches on +// both surfaces because the constant narrows too. The integer family, by +// contrast, keeps such neighbors distinct. +func TestRowVisible_FloatNarrowing(t *testing.T) { + t.Parallel() + f32 := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericFloat, Bits: 32}}} + f64 := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericFloat, Bits: 64}}} + intCol := map[string]ColumnSpec{"v": intNumeric()} + + gt := evalRowFilter(t, map[string]Filter{"v": {Gt: new("16777216")}}, nil) + assert.False(t, gt.RowVisible(map[string]any{"v": json.Number("16777217")}, f32), + "stored Float32(16777217) is 16777216, not > 16777216 — the ordering fail-open, closed") + assert.True(t, gt.RowVisible(map[string]any{"v": json.Number("16777218")}, f32), + "16777218 is Float32-representable and greater on both surfaces") + assert.True(t, gt.RowVisible(map[string]any{"v": json.Number("16777217")}, intCol), + "an integer column stores 16777217 exactly, so the same event IS greater there") + + eq := evalRowFilter(t, map[string]Filter{"v": {Eq: new("16777217")}}, nil) + assert.True(t, eq.RowVisible(map[string]any{"v": json.Number("16777217")}, f32), + "the constant narrows like the stored value — ClickHouse matches `= '16777217'` too") + assert.True(t, eq.RowVisible(map[string]any{"v": json.Number("16777216")}, f32), + "the Float32-equal neighbor matches on both surfaces — the column type gave that distinction away") + assert.False(t, eq.RowVisible(map[string]any{"v": json.Number("16777216")}, intCol), + "integer storage keeps the neighbors distinct") + + eq64 := evalRowFilter(t, map[string]Filter{"v": {Eq: new("9007199254740993")}}, nil) + assert.True(t, eq64.RowVisible(map[string]any{"v": json.Number("9007199254740992")}, f64), + "Float64 column: 2^53 neighbors collapse in the storage domain, matching SQL") + assert.False(t, eq64.RowVisible(map[string]any{"v": json.Number("9007199254740992")}, intCol)) + + // A magnitude the float domain can't hold (ClickHouse would store ±Inf) + // refuses the comparison — withheld, never a guessed verdict. + overflow := evalRowFilter(t, map[string]Filter{"v": {Gt: new("0")}}, nil) + assert.False(t, overflow.RowVisible(map[string]any{"v": json.Number("1e39")}, f32), + "beyond Float32 range ⇒ withhold") +} + +// TestRowVisible_DecimalScaleTruncation: Decimal columns compare after +// truncating BOTH operands to the column's scale — ClickHouse's cast semantics +// (1.005, 1.006 and 1.009 all store as 1.00 in a Decimal(10,2), and a bound +// constant '1.005' truncates the same way, so `= '1.005'` matches a stored +// 1.00 while `> '1.004'` matches nothing; verified on 25.5 and 26.6). +func TestRowVisible_DecimalScaleTruncation(t *testing.T) { + t.Parallel() + dec2 := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericDecimal, Precision: 10, Scale: 2}}} + + gt := evalRowFilter(t, map[string]Filter{"v": {Gt: new("1.004")}}, nil) + assert.False(t, gt.RowVisible(map[string]any{"v": json.Number("1.005")}, dec2), + "stored 1.00 vs constant 1.00: not greater — the pre-narrowing payload must not leak through") + assert.True(t, gt.RowVisible(map[string]any{"v": json.Number("1.02")}, dec2)) + + eq := evalRowFilter(t, map[string]Filter{"v": {Eq: new("1.005")}}, nil) + assert.True(t, eq.RowVisible(map[string]any{"v": json.Number("1.006")}, dec2), + "both operands truncate to 1.00 — ClickHouse matches this pair too") + assert.False(t, eq.RowVisible(map[string]any{"v": json.Number("1.02")}, dec2)) + + lt := evalRowFilter(t, map[string]Filter{"v": {Lt: new("-1")}}, nil) + assert.False(t, lt.RowVisible(map[string]any{"v": json.Number("-1.005")}, dec2), + "truncation is toward zero: -1.005 stores as -1.00, which is not < -1") +} + +// TestRowVisible_IntegerFractionalOperand_FailsClosed: an integer column +// refuses fractional operands on either side — a fractional constant is a +// per-query type error on the SQL path (the role reads no rows there), and a +// fractional payload was never storable in the column — so the stream +// withholds rather than inventing a verdict SQL cannot produce. An integral +// value in fractional SPELLING is a different thing entirely: it +// canonicalizes to its integer and compares normally. +func TestRowVisible_IntegerFractionalOperand_FailsClosed(t *testing.T) { + t.Parallel() + num := map[string]ColumnSpec{"v": intNumeric()} + + frac := evalRowFilter(t, map[string]Filter{"v": {Neq: new("1.5")}}, nil) + assert.False(t, frac.RowVisible(map[string]any{"v": json.Number("2")}, num), + "fractional constant: SQL errors the query, the stream withholds — neither returns rows") + + pay := evalRowFilter(t, map[string]Filter{"v": {Gt: new("1")}}, nil) + assert.False(t, pay.RowVisible(map[string]any{"v": json.Number("2.5")}, num), + "fractional payload was never storable in an integer column") + assert.True(t, pay.RowVisible(map[string]any{"v": json.Number("2.0")}, num), + "integral value in fractional spelling canonicalizes to 2 and compares") +} + +// TestRowVisible_ConstantSpellingFidelity: on an integer column a literal +// constant that is not its own canonical form ("1e3", "1.5") refuses the +// comparison — the SQL path binds the spelling as written and ClickHouse's +// integer cast errors the query there, so the role reads no rows; admitting +// the canonical reading here would deliver rows SQL never returns. Float and +// Decimal casts accept every JSON-number spelling ('1e3' casts to +// Decimal(10,2) as 1000 — verified against ClickHouse), so those families +// compare such constants normally. +func TestRowVisible_ConstantSpellingFidelity(t *testing.T) { + t.Parallel() + intCol := map[string]ColumnSpec{"v": intNumeric()} + dec2 := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericDecimal, Precision: 10, Scale: 2}}} + f32 := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericFloat, Bits: 32}}} + + exp := evalRowFilter(t, map[string]Filter{"v": {Eq: new("1e3")}}, nil) + assert.False(t, exp.RowVisible(map[string]any{"v": json.Number("1000")}, intCol), + "integer column: SQL errors on '1e3', so the stream must not admit its canonical reading") + assert.True(t, exp.RowVisible(map[string]any{"v": json.Number("1000")}, f32), + "float column: ClickHouse casts '1e3' fine, so the stream compares it") + assert.True(t, exp.RowVisible(map[string]any{"v": json.Number("1000")}, dec2), + "Decimal column: ClickHouse casts '1e3' fine too, so the stream compares it") + + trailing := evalRowFilter(t, map[string]Filter{"v": {Gt: new("1.50")}}, nil) + assert.True(t, trailing.RowVisible(map[string]any{"v": json.Number("2")}, dec2), + "a trailing-zero Decimal spelling casts on both surfaces and compares by value") +} + +// TestRowVisible_OutOfRangeOperand_FailsClosed: an operand outside the +// column's numeric range refuses the comparison on either side. ClickHouse's +// reading of such a CONSTANT was measured to vary by pair on one release — +// error (negative vs unsigned: the role reads no rows), mathematical promotion +// ('256' vs UInt8), or a width-boundary wrap that compares against a DIFFERENT +// value than written (2^63 vs Int64 reads as −2^63, where exact-precision +// comparison would admit the −2^63 rows SQL hides under !=) — so no single +// model is safe to reproduce, and refusal is. A PAYLOAD out of range was never +// storable (the insert is rejected), so withholding matches the stored world. +func TestRowVisible_OutOfRangeOperand_FailsClosed(t *testing.T) { + t.Parallel() + u64 := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericInteger, Bits: 64, Unsigned: true}}} + u8 := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericInteger, Bits: 8, Unsigned: true}}} + i8 := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericInteger, Bits: 8}}} + dec := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericDecimal, Precision: 10, Scale: 2}}} + + neg := evalRowFilter(t, map[string]Filter{"v": {Gt: new("-5")}}, nil) + assert.False(t, neg.RowVisible(map[string]any{"v": json.Number("7")}, u64), + "negative constant on an unsigned column: SQL errors, the stream must not admit everything") + + wrap := evalRowFilter(t, map[string]Filter{"v": {Neq: new("18446744073709551616")}}, nil) + assert.False(t, wrap.RowVisible(map[string]any{"v": json.Number("7")}, u64), + "a width-boundary constant may wrap on the SQL side — comparing it as written risks admitting rows SQL hides") + + wide := evalRowFilter(t, map[string]Filter{"v": {Lt: new("99999999999999999999999")}}, nil) + assert.False(t, wide.RowVisible(map[string]any{"v": json.Number("7")}, u64), + "a constant past the width has no reliable SQL reading; the stream withholds") + + over8 := evalRowFilter(t, map[string]Filter{"v": {Neq: new("256")}}, nil) + assert.False(t, over8.RowVisible(map[string]any{"v": json.Number("7")}, u8)) + assert.False(t, over8.RowVisible(map[string]any{"v": json.Number("300")}, u8), + "an out-of-range payload was never storable — withheld") + ok8 := evalRowFilter(t, map[string]Filter{"v": {Neq: new("254")}}, nil) + assert.True(t, ok8.RowVisible(map[string]any{"v": json.Number("7")}, u8), "in-range operands still compare") + + i8lo := evalRowFilter(t, map[string]Filter{"v": {Gt: new("-129")}}, nil) + assert.False(t, i8lo.RowVisible(map[string]any{"v": json.Number("0")}, i8)) + i8ok := evalRowFilter(t, map[string]Filter{"v": {Gt: new("-128")}}, nil) + assert.True(t, i8ok.RowVisible(map[string]any{"v": json.Number("0")}, i8), "the signed minimum itself is in range") + + prec := evalRowFilter(t, map[string]Filter{"v": {Eq: new("999999999")}}, nil) + assert.False(t, prec.RowVisible(map[string]any{"v": json.Number("5")}, dec), + "9 integer digits exceed Decimal(10,2)'s 8-digit budget: unmodelable on the SQL side, withheld here") + precOK := evalRowFilter(t, map[string]Filter{"v": {Eq: new("99999999")}}, nil) + assert.True(t, precOK.RowVisible(map[string]any{"v": json.Number("99999999")}, dec), "the budget's edge is in range") + + // A hand-built spec with an incoherent Scale must degrade to refusal — + // never a panic on the fan-out goroutine (truncateScale slices by Scale). + badScale := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericDecimal, Precision: 10, Scale: -1}}} + assert.NotPanics(t, func() { + assert.False(t, precOK.RowVisible(map[string]any{"v": json.Number("1.5")}, badScale)) + }) +} + +// TestRowVisible_NumericWithoutStorageModel_FailsClosed pins the NumericSpec +// zero value: a ColumnNumeric spec carrying no storage model must refuse every +// comparison, so a numeric type the classifier doesn't recognize (or a caller +// that forgot to set the model) can never compare under guessed semantics. +func TestRowVisible_NumericWithoutStorageModel_FailsClosed(t *testing.T) { + t.Parallel() + perms := evalRowFilter(t, map[string]Filter{"v": {Eq: new("1")}}, nil) + + unmodeled := map[string]ColumnSpec{"v": {Kind: ColumnNumeric}} + assert.False(t, perms.RowVisible(map[string]any{"v": json.Number("1")}, unmodeled)) + + // A float family with no (or a bogus) bit width must refuse too: + // ParseFloat would silently treat any other bitSize as 64 and compare a + // Float32 column in the wider domain — the fail-open direction. + widthless := map[string]ColumnSpec{"v": {Kind: ColumnNumeric, Numeric: NumericSpec{Family: NumericFloat}}} + assert.False(t, perms.RowVisible(map[string]any{"v": json.Number("1")}, widthless)) +} diff --git a/internal/stream/hub.go b/internal/stream/hub.go index 619fae6c..64c7c832 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -206,7 +206,23 @@ func (h *Hub) columnSpecs(table string) map[string]policy.ColumnSpec { } switch { case discovery.IsNumericType(c.Type): - m[c.Name] = policy.ColumnSpec{Kind: policy.ColumnNumeric} + // The storage model narrows both comparison operands the way + // ClickHouse narrows the stored value and the bound constant. A + // numeric type whose model can't be classified keeps the zero + // NumericSpec, which refuses every comparison — fail closed, + // never a comparison under guessed semantics. + spec := policy.ColumnSpec{Kind: policy.ColumnNumeric} + if st, ok := discovery.NumericStorageOf(c.Type); ok { + switch { + case st.Integer: + spec.Numeric = policy.NumericSpec{Family: policy.NumericInteger, Bits: st.IntBits, Unsigned: st.Unsigned} + case st.FloatBits != 0: + spec.Numeric = policy.NumericSpec{Family: policy.NumericFloat, Bits: st.FloatBits} + default: + spec.Numeric = policy.NumericSpec{Family: policy.NumericDecimal, Precision: st.Precision, Scale: st.Scale} + } + } + m[c.Name] = spec case discovery.IsStringType(c.Type): m[c.Name] = policy.ColumnSpec{Kind: policy.ColumnText} } diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index 225582bb..b1aa4e69 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -388,6 +388,36 @@ func TestHub_RowFilter_NumericOrdering_SchemaInformed(t *testing.T) { assertNoFrame(t, blind) } +// TestHub_RowFilter_FloatNarrowing_SchemaInformed drives storage-domain +// narrowing end-to-end through the registry: on a Float32 column, payload +// 16777217 stores as 16777216, so a `_gt: "16777216"` filter must withhold the +// event — the query path's WHERE over the stored row is false, and delivering +// the pre-narrowing payload was the ordering fail-open raised in review. A +// Float32-representable greater value still delivers. +func TestHub_RowFilter_FloatNarrowing_SchemaInformed(t *testing.T) { + t.Parallel() + reg := testutil.NewTestSchemaRegistry(t, []*discovery.TableSchema{ + {Name: "clicks", Columns: []discovery.Column{{Name: "score", Type: "Float32"}}}, + }) + p := &policy.Policy{ + Tables: map[string]policy.TablePolicy{ + "clicks": {Select: map[string]policy.RolePermissions{ + "viewer": {Filter: map[string]policy.Filter{"score": {Gt: new("16777216")}}}, + }}, + }, + } + hub := NewHub(policy.NewMemoryStore(p), reg, nil) + const topic = "ingest.clicks" + sub := NewSubscriber(nil) + hub.Add(topic, "viewer", sub) + + hub.Broadcast(topic, rawEvent(t, "clicks", "t1", map[string]any{"score": json.Number("16777217")})) + assertNoFrame(t, sub) // stores as 16777216: not greater once both operands narrow + + hub.Broadcast(topic, rawEvent(t, "clicks", "t2", map[string]any{"score": json.Number("16777218")})) + assert.Equal(t, float64(16777218), frameData(t, recvFrame(t, sub))["data"].(map[string]any)["score"]) +} + func TestHub_TopicIsolation(t *testing.T) { t.Parallel() hub := NewHub(nil, nil, nil) diff --git a/tests/e2e/sdk/streaming.test.ts b/tests/e2e/sdk/streaming.test.ts index 5950594b..01af9836 100644 --- a/tests/e2e/sdk/streaming.test.ts +++ b/tests/e2e/sdk/streaming.test.ts @@ -37,6 +37,13 @@ describe("Streaming", () => { allow_columns: ["*"], filter: { country: { _eq: "{{ jwt.country }}" } }, }, + // 'metered' carries a numeric literal bound, so the SSE fan-out exercises + // the storage-domain numeric comparison (canonical decimal + integer range + // gate) end to end, not just String equality scoping. + metered: { + allow_columns: ["*"], + filter: { duration_ms: { _gt: "100" } }, + }, }; publicPolicy.tables[T.events].select = { ...(publicPolicy.tables[T.events].select || {}), @@ -248,5 +255,40 @@ describe("Streaming", () => { caStream.close(); } }); + + it("evaluates a numeric row filter in the column's storage domain (UInt32 threshold)", async () => { + // 'metered' scopes delivery to duration_ms > 100 over a UInt32 column: the + // constant routes through the canonical-decimal reading and the integer + // range gate, and both operands compare in the column's storage domain — + // the #381 storage-domain path, pinned end to end on SSE. + const inserter = dataClient(); + const client = authClient("metered"); + const events: any[] = []; + const lowId = testId(); + const highId = testId(); + + const stream = client.from(T.clicks).stream(); + let unsub: (() => void) | undefined; + try { + unsub = stream.subscribe({ + next: (e) => events.push(e), + error: (err) => console.error("metered SSE error:", err), + }); + await stream.connected(20_000); + + const base = { page: "/metered", user_id: "u", session_id: "s" }; + // Below the bound first, above it second: frames on one connection are + // strictly ordered behind the same subject, so receiving the high row + // proves the low row was withheld, not merely late. + await inserter.from(T.clicks).insert({ ...base, event_id: lowId, duration_ms: 100 }); + await inserter.from(T.clicks).insert({ ...base, event_id: highId, duration_ms: 250 }); + + await waitForCondition(() => events.some((e) => e.data?.event_id === highId), 10_000); + expect(events.some((e) => e.data?.event_id === lowId)).toBe(false); + } finally { + if (unsub) unsub(); + stream.close(); + } + }); }); }); diff --git a/tests/integration/rowfilter_narrowing_test.go b/tests/integration/rowfilter_narrowing_test.go new file mode 100644 index 00000000..1157a169 --- /dev/null +++ b/tests/integration/rowfilter_narrowing_test.go @@ -0,0 +1,230 @@ +//go:build integration + +package tests + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Wave-RF/WaveHouse/internal/discovery" + "github.com/Wave-RF/WaveHouse/internal/policy" +) + +// TestRowFilterNumeric_DifferentialAgainstClickHouse pins the stream/query +// row-visibility agreement with ClickHouse itself as the oracle (the #381 +// review's storage-narrowing fail-open): for every numeric column shape × +// insertable payload × filter constant × operator, the in-memory verdict +// (policy.Evaluate → RowVisible over the pre-insert payload, specs built the +// way stream.Hub builds them) must equal what a structured query returns over +// the STORED row — `WHERE v ?` with the constant bound exactly as +// predicatesToSQL binds it. A constant ClickHouse rejects with a type error +// means the role reads no rows on the query path, so the stream must withhold +// too. Inserts go through the worker's exact HTTP surface (JSONEachRow), so +// storage narrowing — Float32/Float64 rounding, Decimal scale truncation — is +// ClickHouse's own, not a lookalike. +func TestRowFilterNumeric_DifferentialAgainstClickHouse(t *testing.T) { + shapes := []struct { + name string + ddl string + payloads []any + constants []string + // looseConstants are out-of-range spellings whose ClickHouse reading + // was measured to vary by pair on one release — mathematical promotion + // ('256' vs UInt8), or a width-boundary WRAP that compares against a + // different value than written (2^63 vs Int64 reads as −2^63). The + // stream refuses them all (the range gate), so verdicts legitimately + // diverge in the withholding direction; only the subset half of the + // guarantee is asserted here: the stream must never admit where SQL + // hides. + looseConstants []string + }{ + { + name: "uint64", + ddl: "UInt64", + payloads: []any{ + json.Number("16777217"), + json.Number("9007199254740992"), + json.Number("9007199254740993"), // 2^53+1: float64 would collapse it onto its neighbor + "12345678901234567890", // string-encoded (the JS-precision-loss escape hatch), > 2^63 + json.Number("0"), + }, + constants: []string{ + "16777216", "16777217", + "9007199254740992", "9007199254740993", + "12345678901234567890", + "1e3", // exponent spelling: ClickHouse's integer cast errors the query + "1.5", // fractional constant: same + "-5", // negative vs unsigned: measured cast error, role reads no rows — strict parity holds + }, + // Wide or width-boundary constants: ClickHouse's reading varies by + // pair (promotion vs wrap); the stream refuses — subset assertion. + looseConstants: []string{"18446744073709551616", "99999999999999999999999"}, + }, + { + name: "int64", + ddl: "Int64", + payloads: []any{json.Number("-5"), json.Number("9007199254740993")}, + constants: []string{ + "-4", "-5", "9007199254740992", + }, + // 2^63 vs Int64 was measured to WRAP (compares as −2^63) — the + // exact case the range gate exists for; subset assertion only. + looseConstants: []string{"9223372036854775808"}, + }, + { + name: "uint8", + ddl: "UInt8", + payloads: []any{json.Number("0"), json.Number("5"), json.Number("255")}, + constants: []string{ + "5", "255", + "-1", // negative vs unsigned: measured cast error — strict parity holds + }, + // Past the width, ClickHouse promotes and compares mathematically + // while the stream refuses — subset assertion only. + looseConstants: []string{"256", "300"}, + }, + { + name: "float32", + ddl: "Float32", + payloads: []any{ + json.Number("16777217"), // stores as 16777216 — the review repro + json.Number("16777218"), + json.Number("0.1"), + json.Number("1.5"), + }, + constants: []string{"16777216", "16777217", "0.1", "1.5", "2", "1e3"}, + }, + { + name: "float64", + ddl: "Float64", + payloads: []any{ + json.Number("9007199254740992"), + json.Number("9007199254740993"), // stores rounded: the storage domain collapses it + json.Number("0.1"), + }, + constants: []string{"9007199254740992", "9007199254740993", "0.1"}, + }, + { + name: "decimal_10_2", + ddl: "Decimal(10, 2)", + payloads: []any{ + json.Number("1.005"), // stores as 1.00 (truncation, not rounding) + json.Number("1.006"), + json.Number("1.02"), + json.Number("-1.005"), + }, + constants: []string{"1.005", "1.004", "1", "1.5", "-1", "1.50", "1e3"}, + looseConstants: []string{"999999999"}, // past Precision−Scale: promoted on the SQL side, refused here + }, + } + + ops := []string{"=", "!=", ">", "<"} + + for _, sh := range shapes { + t.Run(sh.name, func(t *testing.T) { + t.Parallel() + table := createTable(t, "id UInt32, v "+sh.ddl, "ORDER BY id") + spec := numericColumnSpec(t, sh.ddl) + + inserted := make(map[int]any, len(sh.payloads)) + for i, payload := range sh.payloads { + if err := insertBestEffort(t, table, map[string]any{"id": uint32(i), "v": payload}); err != nil { + // Un-storable payloads are the documented transient (DLQ) + // class, out of the parity claim — skip, on the record. + t.Logf("payload %v not insertable into %s (%v); skipping", payload, sh.ddl, err) + continue + } + inserted[i] = payload + } + require.NotEmpty(t, inserted, "corpus must contain insertable payloads") + + for id, payload := range inserted { + for _, constant := range sh.constants { + for _, op := range ops { + stream := streamVerdict(t, table, op, constant, payload, spec) + sql, sqlErr := storedVerdict(t, table, uint32(id), op, constant) + if stream != sql { + t.Errorf("%s: payload %v %s %q — stream says %v, ClickHouse says %v (query err: %v)", + sh.ddl, payload, op, constant, stream, sql, sqlErr) + } + } + } + for _, constant := range sh.looseConstants { + for _, op := range ops { + stream := streamVerdict(t, table, op, constant, payload, spec) + sql, sqlErr := storedVerdict(t, table, uint32(id), op, constant) + if stream && !sql { + t.Errorf("%s: payload %v %s %q — stream admits where ClickHouse hides (query err: %v)", + sh.ddl, payload, op, constant, sqlErr) + } + } + } + } + }) + } +} + +// numericColumnSpec builds the policy.ColumnSpec for a numeric ClickHouse type +// exactly the way stream.Hub's columnSpecs does, from the same classifier. +func numericColumnSpec(t *testing.T, chType string) policy.ColumnSpec { + t.Helper() + st, ok := discovery.NumericStorageOf(chType) + require.True(t, ok, "corpus types must classify: %s", chType) + spec := policy.ColumnSpec{Kind: policy.ColumnNumeric} + switch { + case st.Integer: + spec.Numeric = policy.NumericSpec{Family: policy.NumericInteger, Bits: st.IntBits, Unsigned: st.Unsigned} + case st.FloatBits != 0: + spec.Numeric = policy.NumericSpec{Family: policy.NumericFloat, Bits: st.FloatBits} + default: + spec.Numeric = policy.NumericSpec{Family: policy.NumericDecimal, Precision: st.Precision, Scale: st.Scale} + } + return spec +} + +// streamVerdict resolves a one-operator literal filter through the full +// production path (Evaluate → RowVisible) and reports whether the stream +// would deliver the payload's event. +func streamVerdict(t *testing.T, table, op, constant string, payload any, spec policy.ColumnSpec) bool { + t.Helper() + f := policy.Filter{} + switch op { + case "=": + f.Eq = &constant + case "!=": + f.Neq = &constant + case ">": + f.Gt = &constant + case "<": + f.Lt = &constant + default: + t.Fatalf("unknown op %q", op) + } + p := &policy.Policy{Tables: map[string]policy.TablePolicy{ + table: {Select: map[string]policy.RolePermissions{"r": {Filter: map[string]policy.Filter{"v": f}}}}, + }} + perms := policy.Evaluate(p, "r", table, "select", nil) + require.True(t, perms.Allowed) + return perms.RowVisible(map[string]any{"v": payload}, map[string]policy.ColumnSpec{"v": spec}) +} + +// storedVerdict asks ClickHouse whether the stored row satisfies the predicate, +// with the constant bound as a positional parameter exactly like +// predicatesToSQL emits it. A query error (an exact-domain cast rejecting the +// constant's spelling) means the role reads no rows on that path. +func storedVerdict(t *testing.T, table string, id uint32, op, constant string) (bool, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var cnt uint64 + q := fmt.Sprintf("SELECT count() FROM %s WHERE id = ? AND v %s ?", table, op) + if err := sharedEnv.chConn.QueryRow(ctx, q, id, constant).Scan(&cnt); err != nil { + return false, err + } + return cnt == 1, nil +} From a84fd3d6a51fb01dcc25692ffcfb628b3388e121 Mon Sep 17 00:00:00 2001 From: taitelee Date: Fri, 14 Aug 2026 14:57:42 -0400 Subject: [PATCH 27/27] refactor: split policy canonical/numeric files; count SSE drops in Send --- CHANGELOG.md | 4 +- docs/src/content/docs/access-control.mdx | 2 +- docs/src/content/docs/api.md | 2 +- docs/src/content/docs/architecture.md | 12 +- docs/src/content/docs/sdk/streaming.md | 2 +- internal/api/stream.go | 2 +- internal/policy/canonical.go | 273 ++++++++++++++++++ internal/policy/numeric.go | 260 +++++++++++++++++ internal/policy/policy.go | 212 -------------- internal/policy/rowfilter.go | 273 ------------------ internal/stream/bucket.go | 7 +- internal/stream/bucket_test.go | 12 +- internal/stream/heartbeat_test.go | 12 +- internal/stream/hub.go | 71 +++-- internal/stream/hub_test.go | 121 ++++++-- internal/stream/subscriber.go | 27 +- internal/stream/subscriber_test.go | 4 +- tests/integration/rowfilter_narrowing_test.go | 16 +- 18 files changed, 719 insertions(+), 593 deletions(-) create mode 100644 internal/policy/canonical.go create mode 100644 internal/policy/numeric.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cc99292b..f90e72b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@wavehouse/sdk` `engines.node` floor back to `>=22`, matching the only line we test** (`clients/ts/package.json`, `clients/ts/README.md`, `docs/src/content/docs/sdk/index.mdx`, `docs/src/content/docs/sdk/queries.md`, `pnpm-workspace.yaml`): the floor was relaxed to `>=18` when the browser-first distribution landed (see the entry below), on the reasoning that the runtime needs only `fetch`. Nothing ever tested 18, though — `.nvmrc` pins 22 and `.github/actions/setup-env` consumes it via `node-version-file`, so 22 is the single version CI exercises — and Node 18 and 20 have both since reached upstream end-of-life. Declaring a floor we neither test nor is supported upstream promises more than it can back, so it returns to `>=22`. **Consumer impact:** installing on Node < 22 now warns with `EBADENGINE` under npm, and fails outright under pnpm with `engine-strict` enabled. The SDK README and the docs' Runtime support section state the requirement, which they previously either omitted or quoted as 18. -- **Live SSE events are now projected and serialized once per role instead of once per subscriber** (`internal/stream/hub.go` (new), `internal/stream/{subscriber,bucket,heartbeat,metrics,doc}.go`, `internal/api/stream.go`, `internal/api/hub.go` + `internal/api/transform.go` (both removed — the broadcast hub moves to `internal/stream`, and the orphaned test-only `transformForClient` is dropped), `cmd/wavehouse/main.go`, `docs/src/content/docs/architecture.md`, `AGENTS.md`, plus tests in `internal/stream/{hub,filter,subscriber,bucket,heartbeat}_test.go` and `internal/api/{stream,transform,router,errors}_test.go`): the first PR of the SSE delivery-path throughput epic ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)), building on the `internal/stream` primitives from #346. The broadcast hub moves into `internal/stream` as `Hub`: subscribers register under `(topic, role)`, and `Broadcast` decodes each event **once**, applies each subscribed role's column policy **once**, builds one SSE frame per role, and fans it to every member of that role's `Bucket`. Previously every connection independently ran `json.Unmarshal → policy.Evaluate → filterEventColumns → json.Marshal` (plus a second unmarshal just to read the `id:` timestamp) on the *same* event in its own read loop — byte-identical work repeated N times. For a single-role audience (the public dashboard, every viewer `public`) that collapses N re-projections to 1, moving the measured ~2 270 deliveries/s ceiling toward an events/s ceiling. The `(topic, role)` key is sufficient for the *column* projection because column visibility derives only from the role+table policy entry. (As first written, this entry also documented a "stream path applies no row-level filter" invariant, with a note that adding one would need a claims-aware key — **superseded** by the row-`filter` Security entry in this same block: the stream now applies a role's row filter per subscriber, and the key deliberately stays `(topic, role)`, each subscriber's claims gating delivery inside the bucket rather than fragmenting the projection key.) The handler's two `select` cases (keepalive vs. per-subscriber event) collapse into one byte-pump over a single `Subscriber.Frames()` queue carrying typed `Frame`s; the subscriber queue grows from cap 1 (keepalive-only) to 64 so live events buffer while the handler is mid-write. Gap-fill replay and `Last-Event-ID`/`?since=` resumption are unchanged (replay stays per-connection via the shared `stream.ReplayProjector`; live frames carry the same `id: `). Slow-consumer drops, silent before, now increment `wavehouse_sse_dropped_frames_total`; an inert `Subscriber.Evicted()` seam is wired for the eviction follow-up. The per-delivery OpenTelemetry span (another #294 item) was already removed in #346. **Deferred to follow-ups:** active slow-consumer eviction (#94) and right-sizing the subscriber buffer + broadcast lock cost (#152). +- **Live SSE events are now projected and serialized once per role instead of once per subscriber** (`internal/stream/hub.go` (new), `internal/stream/{subscriber,bucket,heartbeat,metrics,doc}.go`, `internal/api/stream.go`, `internal/api/hub.go` + `internal/api/transform.go` (both removed — the broadcast hub moves to `internal/stream`, and the orphaned test-only `transformForClient` is dropped), `cmd/wavehouse/main.go`, `docs/src/content/docs/architecture.md`, `AGENTS.md`, plus tests in `internal/stream/{hub,filter,subscriber,bucket,heartbeat}_test.go` and `internal/api/{stream,transform,router,errors}_test.go`): the first PR of the SSE delivery-path throughput epic ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)), building on the `internal/stream` primitives from #346. The broadcast hub moves into `internal/stream` as `Hub`: subscribers register under `(topic, role)`, and `Broadcast` decodes each event **once**, applies each subscribed role's column policy **once**, builds one SSE frame per role, and fans it to every member of that role's `Bucket`. Previously every connection independently ran `json.Unmarshal → policy.Evaluate → filterEventColumns → json.Marshal` (plus a second unmarshal just to read the `id:` timestamp) on the *same* event in its own read loop — byte-identical work repeated N times. For a single-role audience (the public dashboard, every viewer `public`) that collapses N re-projections to 1, moving the measured ~2 270 deliveries/s ceiling toward an events/s ceiling. The `(topic, role)` key is sufficient for the *column* projection because column visibility derives only from the role+table policy entry. (As first written, this entry also documented a "stream path applies no row-level filter" invariant, with a note that adding one would need a claims-aware key — **superseded** by the row-`filter` Security entry in this same block: the stream now applies a role's row filter per subscriber, and the key deliberately stays `(topic, role)`, each subscriber's claims gating delivery inside the bucket rather than fragmenting the projection key.) The handler's two `select` cases (keepalive vs. per-subscriber event) collapse into one byte-pump over a single `Subscriber.Frames()` queue carrying typed `Frame`s; the subscriber queue grows from cap 1 (keepalive-only) to 64 so live events buffer while the handler is mid-write. Gap-fill replay and `Last-Event-ID`/`?since=` resumption are unchanged (replay stays per-connection via the shared `stream.ReplayProjector`; live frames carry the same `id: `). Slow-consumer drops, silent before, now increment `wavehouse_sse_dropped_frames_total` (counted inside `Subscriber.Send` itself, labeled by frame kind — a keepalive dropped by a full queue counts too); an inert `Subscriber.Evicted()` seam is wired for the eviction follow-up. The per-delivery OpenTelemetry span (another #294 item) was already removed in #346. **Deferred to follow-ups:** active slow-consumer eviction (#94) and right-sizing the subscriber buffer + broadcast lock cost (#152). - **CI is now a job DAG instead of one monolithic job, and the docs deploys no longer expose the Cloudflare token to PR-authored code** (`.github/workflows/ci.yml`, `.github/workflows/housekeeping.yml`, `.github/actions/setup-env/action.yml`, `Makefile`, `docs/wrangler.jsonc`, `AGENTS.md`, `docs/src/content/docs/development.md`, `docs/src/content/docs/claude-code.md`, `CONTRIBUTING.md`, `scripts/lint-pr-title.sh`, `.claude/hooks/agent-bash-gate.sh`): closes #305. The single `make ci` job becomes parallel jobs over the *same Makefile targets* (local `make ci` stays the dev mirror): `lint`, `unit`, `integration`, `e2e` (builds its own SDK dist + cover binary via `make -j test-e2e` on a warm per-suite cache and runs the suite exactly like a local run), `coverage` (a dedicated job that merges every suite's `coverage-` fragment and applies every threshold gate via `make cov` — like local `make ci`'s final step, so the gate is decoupled from the e2e suite; it's `needs: changes` only and *polls* for the fragments rather than `needs`-ing the suites, so its setup overlaps them and the merge fires ~10s after the last suite instead of serializing ~50s of setup onto the critical path), and `docs-build` (`make build-docs`, docs-affecting changes only, uploads the docs dist artifact the preview/deploy jobs consume) — public-repo runners are free and 4-core, so the pipeline spreads horizontally instead of queueing in one process. The architecture is documented once, in `.github/workflows/README.md` (DAG diagram, design invariants, cache key policy, add-a-job recipe, and the measured-but-deferred optimizations — e2e sharding among them), and the workflow's logic lives in shellcheck-gated scripts (`scripts/ci/` — `classify-changes.sh`, `check-pr-title.sh`, `docs-preview-comment.sh`, `timing-summary.sh`, `wait-artifact.sh`; over the shared, dependency-free path classifier `scripts/classify-paths.sh`, unit-tested by `scripts/classify-paths.test.sh` via `make test-classify-paths` and reused by the `pre-push` git hook so a docs/prose-only push requires only `make verify`, not a full `make ci` — the same suites CI skips for those changes) rather than inline YAML; caches are owned end-to-end by `setup-env` via nested `actions/cache` (automatic post-job saves — the per-job save-step boilerplate is gone); a non-gating `Timing summary` job writes a per-job wall-clock table to every run's Summary page; and `make verify` gains two leaves that gate the new surface area — `lint-sh` (shellcheck `v0.11.0`, checksum-verified install via `scripts/install-shellcheck.sh`) and `lint-gha` (actionlint `v1.7.12`) — so the CI plumbing is linted like any other source. The workflow also handles `merge_group` events (full suite against the merge-group ref), enabling a **merge queue** on `main`: the queue re-tests each PR against current main at landing time, which replaces the ruleset's "require branches to be up to date" rule — no more manual branch updates after every sibling merge. A new aggregator job named `CI` is the ruleset's **sole required status check** (it fails on any failed/cancelled job and counts skipped jobs as passing), so docs-only PRs skip the Go suites without orphaning the gate and future job changes never require ruleset edits. The PR-title (Conventional Commits) gate moves into the `PR title` job under that aggregator, validated by the same `scripts/lint-pr-title.sh` from a trusted `main` checkout; `PR housekeeping` (`pull_request_target`) drops to non-required and keeps what needs fork-PR write access — path labels, the sticky title-explainer comment, and a new nudge that re-runs the failed `PR title` job when a title edit fixes it (the job re-reads the title from the API, so no new push is needed). The **#305 fix**: docs previews/production deploys run in dedicated `docs-preview`/`docs-deploy` jobs that check out trusted `main` (wrangler, worker source, and config never resolve from the PR tree), consume only the static `docs/dist` artifact, and are the only jobs that reference `CLOUDFLARE_*` secrets; previews now publish right after `docs-build` instead of waiting on the full test pipeline, and the `docs-preview` deploy is **non-gating** — it's not in the `CI` aggregator's `needs` (only `docs-build` gates), so a slow or failed Cloudflare preview reports its own "Docs preview" check but never delays or reds the required check; production (`docs-deploy`, on the post-merge main push) still requires everything green. Per-job least-privilege permissions replace the old workflow-wide `contents: write`, and the Go build cache is partitioned per job (unit/integration/e2e compile with different flags) so each suite stays warm. @@ -45,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/stream/metrics.go`, `internal/policy/policy.go`, `internal/policy/rowfilter.go` (new), `internal/discovery/validation.go`, `internal/discovery/timestamp.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/api.md`, `docs/src/content/docs/sdk/streaming.md`, `AGENTS.md`, `SECURITY.md`, `internal/stream/doc.go`, plus tests in `internal/policy/rowfilter_test.go` (new), `tests/integration/rowfilter_narrowing_test.go` (new), `internal/policy/policy_test.go`, `internal/discovery/validation_test.go`, `internal/discovery/timestamp_test.go`, `internal/stream/hub_test.go`, `tests/e2e/sdk/streaming.test.ts`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). The in-memory comparison is type-aware and **fails closed on anything it can't prove** (`policy.ColumnKind`, seeded from the schema registry): numeric columns (`Int*`/`UInt*`/`Float*`/`Decimal*`) compare in the column's **storage domain**, built on the same canonical machinery #457 landed for claims rather than a parallel stack: BOTH operands render to exact canonical decimal form (`CanonicalScalar`/`CanonicalNumericLiteral` — digit-bounded, so an over-long client-controlled operand is refused before any superlinear work; the hub decodes payloads with `UseNumber`, so 64-bit IDs keep exact digits whether string-encoded — the JS-precision-loss escape hatch — or bare, and big integers stay byte-faithful on the SSE wire) and a digit-string comparator (`compareCanonicalDecimals`) orders them — the former float64/`math/big` comparison stack is deleted. The operands are then narrowed the way ClickHouse narrows the stored value at insert AND the bound constant at compare (`policy.NumericSpec`, classified per column by `discovery.NumericStorageOf`): `Float32`/`Float64` round to the column's width, `Decimal` truncates at its scale, and integer columns are exact at any width, refusing fractional operands and non-plain constant spellings (`'1e3'` errors ClickHouse's integer cast per query, so the stream withholds to match; Float and Decimal casts accept every JSON-number spelling — verified — so those compare by value). Both operands are also **range-gated** per column (`Int*`/`UInt*` width bounds, the Decimal precision budget): ClickHouse's reading of an out-of-range constant was measured to vary on a single release between a query error (a negative bound on an unsigned column — the role reads no rows), a mathematical promotion (`'256'` against a `UInt8`), and a width-boundary wrap onto a *different* value than written (`'9223372036854775808'` against an `Int64` compares as −2^63, where exact-precision comparison would have admitted the −2^63 rows SQL hides under `_neq`) — so the stream refuses all of them rather than model any one behavior, an out-of-range payload was never storable regardless, and the differential oracle asserts strict parity for the error class plus the never-admit-where-SQL-hides direction for the promotion/wrap class. This closes the review-raised ordering fail-open where a pre-narrowing payload (`16777217` into a `Float32`) satisfied `_gt: "16777216"` on the stream while the stored row (`16777216`) failed it in SQL, and `tests/integration/rowfilter_narrowing_test.go` holds every stream verdict equal to a live ClickHouse's across column shapes × payloads × constants × operators — ClickHouse itself as the oracle, the same differential rigor as #402's timestamp grammar. An operand outside the JSON number grammar (`NaN`, any `Inf` spelling) or beyond the float domain's range withholds the row — `String` columns compare bytewise (exactly ClickHouse's String semantics, equality *and* ordering) — `DateTime`/`DateTime64` columns compare as **instants**, both operands read through the very grammar ingest canonicalization (#402) uses (`discovery.Column.TimeParser`: same spellings and zone rule, truncated to the column's precision, out-of-range instants that insert-time saturation would move refused), so a zone-less filter constant — the spelling query-path SQL wants — matches the canonical RFC 3339 payload the stream carries, time-window `_gt`/`_lt` policies work, and either operand the grammar can't read withholds the row — and every other type (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, IPs, `FixedString`, …) — plus every column when no schema is available (an unknown table, or the boot-time discovery-failure window where the server serves while retrying) — trusts byte-equality only: `_eq`/`_in` admit exactly the event's own text rendering, while `_neq`/`_gt`/`_lt` withhold the row, because a byte difference can be pure representation and admitting on it would deliver rows the query path excludes (an uppercase UUID under `_neq`; `"9" > "100"` as text under `_gt`). The filter *constants* are claims-derived, so the same exactness holds on that side too: the auth middleware parses JWTs with `WithJSONNumber` and every bound constant routes through the shared `policy.CanonicalScalar` rule (both landed with the claim-template entry below — before them, a Snowflake-scale `tenant` claim rendered as `"1e+16"` and the stream *delivered the float64-equal neighbor tenant's rows* while withholding the subscriber's own), and this PR extends `CanonicalScalar` in depth for claims maps that never passed through that parser (a plain `json.Unmarshal` decodes numbers as float64): a float64 at or past 2^53 is refused outright — the predicate then matches **no** rows on either surface (`1 = 0` in SQL, unconditionally false in memory), the same two-surface verdict an unresolvable claim gets — and smaller floats render positionally (`"1000000"`, never the `"1e+06"` spelling ClickHouse integer columns reject with a type error). Ambiguity therefore always costs availability (a row withheld), never confidentiality — a guarantee about the ingested payload the stream evaluates, whose one residual payload-vs-stored asymmetry — an event whose insert later fails outright (out-of-range value, batch error → DLQ) after it was already streamed — is documented in the enforcement caution — and each withhold is observable via the new `wavehouse_sse_rows_withheld_total` counter (labeled by table and role, on the live and replay paths alike), separating "no matching rows" from "a fail-closed filter is withholding everything". Subscriber claims are fixed at construction (`stream.NewSubscriber(claims)` — no setter), so the fan-out's unsynchronized claims read is race-free structurally, with a dedicated `-race` test driving concurrent row-filtered broadcasts. Replay (gap-fill) applies the same per-connection row check via `Hub.ReplayProjector`, which caches the per-table column-kind lookup across the replay loop. **Perf note:** on a topic whose role carries a row-filter, part of the #294/#353 once-per-role fan-out gain is traded back for correctness — `policy.Evaluate` runs per subscriber per event there (the column projection stays shared; roles without a filter are unaffected); #435 tracks memoizing it, and `BenchmarkBroadcast_RowFilteredFanout` exists to measure it. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant originally noted in the #294/#353 Changed entry (under **Changed** in this same block, which now points back here). +- **Live SSE streams now apply a role's row-`filter` per subscriber, closing a query/stream row-level-security drift** (`internal/stream/hub.go`, `internal/stream/subscriber.go`, `internal/stream/metrics.go`, `internal/policy/policy.go`, `internal/policy/{rowfilter,canonical,numeric}.go` (new — predicate evaluation, operand rendering, and storage-domain comparison as three focused files), `internal/discovery/validation.go`, `internal/discovery/timestamp.go`, `internal/api/stream.go`, `cmd/wavehouse/main.go`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/api.md`, `docs/src/content/docs/sdk/streaming.md`, `AGENTS.md`, `SECURITY.md`, `internal/stream/doc.go`, plus tests in `internal/policy/rowfilter_test.go` (new), `tests/integration/rowfilter_narrowing_test.go` (new), `internal/policy/policy_test.go`, `internal/discovery/validation_test.go`, `internal/discovery/timestamp_test.go`, `internal/stream/hub_test.go`, `tests/e2e/sdk/streaming.test.ts`): closes #319. The SSE delivery path stripped denied columns but never applied a role's row-level `filter` predicate, so a subscriber received rows the structured-query path would have filtered out for that same role — a data-exposure on the streaming surface for any table that combines a row-policy with a shared or role-scoped stream (harmless on the public Stats table today, which carries no restrictive row-policy, but real for any private/PII table fronted by a stream). The row-filter is now resolved once into predicates that feed **both** read surfaces — the query path renders them to SQL, the stream evaluates them in memory (`ResolvedPermissions.RowVisible`, evaluated per subscriber against that subscriber's claims via the same `Evaluate` call the query path uses) — so the two can't drift (the row-level analogue of the shared `IsColumnAllowed` decision from #223). Because a row-filter resolves against each subscriber's JWT claims, the #294/#353 once-per-role projection is now claims-aware: a role **without** a filter keeps the pure once-per-role fast path unchanged (zero regression on the public stream), while a role **with** a filter keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (evaluated against the full event, so a filter may key on a column the role can't select). The in-memory comparison is type-aware and **fails closed on anything it can't prove** (`policy.ColumnKind`, seeded from the schema registry): numeric columns (`Int*`/`UInt*`/`Float*`/`Decimal*`) compare in the column's **storage domain**, built on the same canonical machinery #457 landed for claims rather than a parallel stack: BOTH operands render to exact canonical decimal form (`CanonicalScalar`/`CanonicalNumericLiteral` — digit-bounded, so an over-long client-controlled operand is refused before any superlinear work; the hub decodes payloads with `UseNumber`, so 64-bit IDs keep exact digits whether string-encoded — the JS-precision-loss escape hatch — or bare, and big integers stay byte-faithful on the SSE wire) and a digit-string comparator (`compareCanonicalDecimals`) orders them — the former float64/`math/big` comparison stack is deleted. The operands are then narrowed the way ClickHouse narrows the stored value at insert AND the bound constant at compare (`policy.NumericSpec`, classified per column by `discovery.NumericStorageOf`): `Float32`/`Float64` round to the column's width, `Decimal` truncates at its scale, and integer columns are exact at any width, refusing fractional operands and non-plain constant spellings (`'1e3'` errors ClickHouse's integer cast per query, so the stream withholds to match; Float and Decimal casts accept every JSON-number spelling — verified — so those compare by value). Both operands are also **range-gated** per column (`Int*`/`UInt*` width bounds, the Decimal precision budget): ClickHouse's reading of an out-of-range constant was measured to vary on a single release between a query error (a negative bound on an unsigned column — the role reads no rows), a mathematical promotion (`'256'` against a `UInt8`), and a width-boundary wrap onto a *different* value than written (`'9223372036854775808'` against an `Int64` compares as −2^63, where exact-precision comparison would have admitted the −2^63 rows SQL hides under `_neq`) — so the stream refuses all of them rather than model any one behavior, an out-of-range payload was never storable regardless, and the differential oracle asserts strict parity for the error class plus the never-admit-where-SQL-hides direction for the promotion/wrap class. This closes the review-raised ordering fail-open where a pre-narrowing payload (`16777217` into a `Float32`) satisfied `_gt: "16777216"` on the stream while the stored row (`16777216`) failed it in SQL, and `tests/integration/rowfilter_narrowing_test.go` holds every stream verdict equal to a live ClickHouse's across column shapes × payloads × constants × operators — ClickHouse itself as the oracle, the same differential rigor as #402's timestamp grammar. An operand outside the JSON number grammar (`NaN`, any `Inf` spelling) or beyond the float domain's range withholds the row — `String` columns compare bytewise (exactly ClickHouse's String semantics, equality *and* ordering) — `DateTime`/`DateTime64` columns compare as **instants**, both operands read through the very grammar ingest canonicalization (#402) uses (`discovery.Column.TimeParser`: same spellings and zone rule, truncated to the column's precision, out-of-range instants that insert-time saturation would move refused), so a zone-less filter constant — the spelling query-path SQL wants — matches the canonical RFC 3339 payload the stream carries, time-window `_gt`/`_lt` policies work, and either operand the grammar can't read withholds the row — and every other type (`Enum`, `UUID`, `Date`/`Date32`, `Bool`, IPs, `FixedString`, …) — plus every column when no schema is available (an unknown table, or the boot-time discovery-failure window where the server serves while retrying) — trusts byte-equality only: `_eq`/`_in` admit exactly the event's own text rendering, while `_neq`/`_gt`/`_lt` withhold the row, because a byte difference can be pure representation and admitting on it would deliver rows the query path excludes (an uppercase UUID under `_neq`; `"9" > "100"` as text under `_gt`). The filter *constants* are claims-derived, so the same exactness holds on that side too: the auth middleware parses JWTs with `WithJSONNumber` and every bound constant routes through the shared `policy.CanonicalScalar` rule (both landed with the claim-template entry below — before them, a Snowflake-scale `tenant` claim rendered as `"1e+16"` and the stream *delivered the float64-equal neighbor tenant's rows* while withholding the subscriber's own), and this PR extends `CanonicalScalar` in depth for claims maps that never passed through that parser (a plain `json.Unmarshal` decodes numbers as float64): a float64 at or past 2^53 is refused outright — the predicate then matches **no** rows on either surface (`1 = 0` in SQL, unconditionally false in memory), the same two-surface verdict an unresolvable claim gets — and smaller floats render positionally (`"1000000"`, never the `"1e+06"` spelling ClickHouse integer columns reject with a type error). Ambiguity therefore always costs availability (a row withheld), never confidentiality — a guarantee about the ingested payload the stream evaluates, whose one residual payload-vs-stored asymmetry — an event whose insert later fails outright (out-of-range value, batch error → DLQ) after it was already streamed — is documented in the enforcement caution — and each withhold is observable via the new `wavehouse_sse_rows_withheld_total` counter (labeled by table and role, on the live and replay paths alike), separating "no matching rows" from "a fail-closed filter is withholding everything". Subscriber claims are fixed at construction (a `stream.NewSubscriber` argument — no setter), so the fan-out's unsynchronized claims read is race-free structurally, with a dedicated `-race` test driving concurrent row-filtered broadcasts. Replay (gap-fill) applies the same per-connection row check via `Hub.ReplayProjector`, which now holds one policy snapshot for the whole gap-fill (one store read per replay instead of one per replayed event; a policy reload landing mid-replay applies from the first live event) and caches the per-table column-kind lookup across the replay loop. **Perf note:** on a topic whose role carries a row-filter, part of the #294/#353 once-per-role fan-out gain is traded back for correctness — `policy.Evaluate` runs per subscriber per event there (the column projection stays shared; roles without a filter are unaffected); #435 tracks memoizing it, and `BenchmarkBroadcast_RowFilteredFanout` exists to measure it. The resource limits (`max_rows`, `max_execution_time`, …) remain a query-path property and are still **not** applied to the stream (a separate, documented boundary). Supersedes the "stream path applies no row-level filter" invariant originally noted in the #294/#353 Changed entry (under **Changed** in this same block, which now points back here). - **Row-filter claim templates now fail closed on every operator when the token doesn't carry the claim** (`internal/policy/policy.go`, `internal/policy/policy_test.go`, `internal/query/builder.go`, `internal/query/builder_test.go`, `internal/api/structured_query.go`, `internal/api/structured_query_test.go`, `internal/api/ingest.go`, `internal/api/ingest_test.go`, `internal/auth/auth.go`, `internal/auth/auth_test.go`, `internal/stream/hub_test.go`, `docs/src/content/docs/{access-control.mdx,configuration.mdx,deployment.md,pipes.mdx,architecture.md,api.md}`): closes [#385](https://github.com/Wave-RF/WaveHouse/issues/385) — the remaining fail-open half of the class [#224](https://github.com/Wave-RF/WaveHouse/issues/224) opened — and [#322](https://github.com/Wave-RF/WaveHouse/issues/322). An unresolvable `{{ jwt.* }}` template in a `filter` rendered as the empty string and still bound a real predicate for `_eq`/`_neq`/`_gt`/`_lt` — so a validly-signed token missing the claim (mixed IdP audiences, service tokens) got `WHERE tenant_id = ''` (leaking every empty-valued row), and `_neq`/`_gt` on a string column (`col != ''` / `col > ''`) matched essentially all rows, erasing the restriction entirely. Only a bare-claim `_in` failed closed. Now any filter template containing an unresolvable claim path emits the same constant-false predicate `_in` already used (`1 = 0`): on the structured-query path (`POST /v1/query`) the role sees no rows, matching what `access-control.mdx` promised all along (the SSE stream applies the same fail-closed rule per subscriber — see the row-`filter` entry above ([#381](https://github.com/Wave-RF/WaveHouse/pull/381)) — and named pipes authorize by role, not row filter). A template-free literal value — including an explicit `""` — still binds exactly as written, and an `_in` template with surrounding text joins the fail-closed path too (previously it bound the partial literal). A claim that resolves to a JSON **object or array** — usually a dropped path segment, `{{ jwt.app_metadata }}` where `{{ jwt.app_metadata.tenant_id }}` was meant — now also fails closed instead of binding its `map[…]`/`[…]` stringification (the bare-claim `_in` array keeps its multi-tenant semantics, and its **elements** now follow the same rule: one object, null, or nested-array element fails the whole set closed rather than binding a `map[…]`/`` rendering no row legitimately carries), and the JWT parser now decodes numeric claims as `json.Number` (`jwt.WithJSONNumber`), so a numeric id above 2^53 binds digit-exact instead of float64-rounding to a neighboring value. Every bound value flows through one rule, `policy.CanonicalScalar`, which also renders numeric claims in **canonical decimal form** rather than the token's spelling — `1.0`/`1e3` bind as `1`/`1000`, because the literal spelling raises a per-query `TYPE_MISMATCH` against a numeric ClickHouse column. The canonical form is **exact** at every width and precision — integers via big-int, fractions and exponents by digit-string arithmetic, never a float64 round-trip, so `0.1000000000000000000001` keeps every digit and `1e-400` fails closed instead of collapsing to `0` — while a magnitude only JSON can hold (`1e400`, `1e-400`) or a literal (or exact form) past 100 digits fails closed (the exact paths are length-bounded up front — big-int cost is superlinear in digit count and the ingest check path hands it client-controlled literals, so an unbounded literal was a single-request CPU sink) — and the insert-`check` comparison canonicalizes its payload side through the same function, so a numeric insert value matches a numeric claim by value, not by spelling. One deliberate JWT-validation shift rides along with `json.Number` decoding: a literal `exp: 0`, which float64 decoding special-cased as never-expiring, now reads as the epoch, so such tokens are rejected as expired and fall back to the roleless `default_role` like any invalid token. Breaking only for deployments that relied on the fail-open: a role whose token lacks a templated claim now reads nothing instead of *more* than intended. Insert-`check` `_eq` semantics are unchanged — the template still renders (unresolvable placeholder → empty string, surrounding literal text kept) and that rendered value is auto-injected (the required-value question is [#463](https://github.com/Wave-RF/WaveHouse/issues/463)) — but a `check: _in` template with surrounding text and an unresolvable claim now resolves to the empty set (every insert to that column rejected) instead of requiring membership in the partial literal, since the `_in` resolver is shared with the filter path. This PR also closes two adjacent fail-open paths in the same class surfaced in review: a claim template whose path is outside the `{{ jwt. }}` grammar (a hyphen, or a namespaced OIDC URL) is now **rejected when the policy is written** rather than bound as literal `{{…}}` text — a read leak for `_neq`/`_lt` and silent write corruption for `check`. That boundary is asymmetric for a running deployment: a bootstrap policy file carrying such a template makes the server **refuse to start** when the store is seeded from it (a populated KV store skips the file), an admin `PUT` on `/v1/admin/policy` (or a `POST` to its `validate` sibling) returns `400`, and a policy already stored in KV is *not* re-validated when a node loads it ([#461](https://github.com/Wave-RF/WaveHouse/issues/461)) — re-`PUT` it once after upgrading. And the row-filter predicate plus the role's `max_rows` cap are now emitted by `Build` itself, as part of the WHERE/LIMIT assembly it already does (the #322 half of this PR): splicing them into rendered SQL afterward let a crafted aggregation alias or `ORDER BY` alias-reference swallow the `WHERE` splice and delete the row filter — valid SQL returning the whole table, reachable precisely when a filter failed closed — and `ApplyMaxRows`'s uppercase-then-index offset drifted on length-changing runes (a column named `ıı`), silently dropping the cap. `InjectPermissionFilters`, `ApplyMaxRows`, and `findInsertPoint` are deleted, and no interim clause-keyword alias guard ships (one existed only between commits of this PR): a keyword-bearing alias (`Total order by region`) stays a legal identifier, contained by backtick quoting. One more member of that case-folding family: the aggregation-function allowlist is now **ASCII-exact** — `strings.ToLower` folds `İ` (U+0130) to `i`, so an aggregation named `mİn` passed the allowlist and reached ClickHouse verbatim as an unknown function, a per-query `500` where the builder's `400` belongs. Canonicalization is symmetric end to end: the insert-`check` comparison runs its **required side** through `CanonicalScalar` as well as the payload side, and a `check` value with **no placeholder** — which carries no JSON type — additionally matches by its numeric reading at compare time (a static `_eq: "1.0"` accepts an inserted `1.0` and an inserted `"1.0"` alike; without that reading the canonical payload side rejects every numeric insert the check was written to allow) while still binding and auto-injecting exactly as written, so read filters never move (`_neq: "1.0"` on a `String` version column keeps excluding exactly `'1.0'`). The second reading is gated **by type** (`policy.LiteralValue`, which `Evaluate` reserves for placeholder-free values), so a claim-derived value keeps strict canonical equality — a string-typed claim of `"1e3"` never accepts an inserted `1000`. The 100-digit literal bound counts **digits, not bytes** — sign, decimal point, and exponent marker ride free, so `-1e99` and its 101-byte written-out form both bind (the exact-form gate leaves two characters of slack past the bound, so a borderline exponent spelling like `1e101` can resolve where its written-out digits could not — the literal-side gate is the stricter of the two). The exact-form bound is **stricter than the old float64 path for wide magnitudes**: values like `1e150` or `1e-150`, whose exact decimal expansions exceed 100 digits, previously resolved (rounded) and now fail closed — on a read filter the role sees no rows; on an insert `check` the claim routes to the #463 auto-inject path, where an integer or `Decimal` column coerces the stamped `''` to `0`. **Upgrade note (data migration):** a pre-upgrade build auto-injected numeric claims above 2^53 in float64-rounded form, so rows it stamped carry a neighboring value of the true id (Snowflake-scale ids, ~1.7e18, are wide enough); the exact filter binds the true value, so those rows don't become wrong after upgrading — they become silently **unreachable** through the writer's own row filter. Before relying on the new filter, reconcile such rows (e.g. `ALTER TABLE … UPDATE` the scoped column from the rounded value to the exact claim value) — rounded and exact ids differ only above 2^53. - **A `?token=` query credential is now stripped from the request URL whichever credential wins, not only when it is the one used** (`internal/auth/auth.go`, `internal/auth/auth_test.go`, `docs/src/content/docs/api.md`, `docs/src/content/docs/reverse-proxy.mdx`, `docs/src/content/docs/sdk/index.mdx`): raised by CodeRabbit on [#448](https://github.com/Wave-RF/WaveHouse/pull/448). `bearerToken` returned from the `Authorization: Bearer` branch *before* the strip, so a request presenting both credentials — which any caller can do — left the unused JWT sitting in `r.URL` for the rest of the request's life. The operator-key path had the same shape one frame up, returning before `bearerToken` ran at all, so the strip is now resolved ahead of that branch too. Not an active leak today (WaveHouse's own request logging only ever records `r.URL.Path`, and the OTel HTTP instrumentation records no query attribute), so this is defense in depth rather than a fix for an observed exposure — but it closed an inconsistency in an invariant the code already asserted on the query-only path, where any later handler or future logging change would have turned it into one. The strip now runs once, before either credential path returns; header precedence is unchanged, unrelated query parameters survive, and both cases are pinned by tests. The docs correspondingly drop the "the header path leaves the query parameter untouched" caveat that described the old behavior. - **Policy `_in` is now enforced on both the row-`filter` and insert-`check` paths, closing a fail-open row-security gap** (`internal/policy/policy.go`, `internal/api/ingest.go`, `docs/src/content/docs/access-control.mdx`, plus tests in `internal/policy/policy_test.go`, `internal/api/ingest_test.go`): closes #224. The `Filter` schema accepted `_in` but the engine never read it: on the row-`filter`/SELECT path `resolveFilters` had no `_in` branch, so a row-security filter like `tenant_id: { _in: … }` produced **no `WHERE` predicate** and the role saw every row instead of its tenant subset (a fail-open, same family as #223); on the `check`/INSERT path only `_eq` was honored, silently dropping any other operator. `_in` now takes a single claim that resolves to a JSON **array** (the multi-tenant case — a token's `tenant_ids` list) and emits `col IN (?, …)` with one bound param per element; a scalar claim is a one-element set, and an empty/absent claim matches **no rows** (fail-closed) rather than widening to all of them. On the insert path an `_in` check requires the column be present and one of the set — there is no single value to auto-inject as `_eq` does, so an omitted column is rejected (`403 check failed`). The comparison operators are enforced on `filter` (`_eq`/`_neq`/`_gt`/`_lt`/`_in` all produce predicates now, so nothing is rejected there) and, on `check`, `_neq`/`_gt`/`_lt` become a loud config-load rejection (no insert-time semantics; `check` honors `_eq` + `_in`). The `_in` value stays a single templated string in the wire schema (Go `Filter.In`, SDK `PolicyFilter._in`), matching the established "set = array" shape of the caller-query `in` operator. diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx index d8dd5893..c2838a01 100644 --- a/docs/src/content/docs/access-control.mdx +++ b/docs/src/content/docs/access-control.mdx @@ -397,7 +397,7 @@ A few more edges worth knowing when you write a policy — the stream evaluates - **A non-scalar event value** (array/object/null) under a filtered column withholds the row. - **Insert-time numeric narrowing is simulated, not skipped.** The insert narrows a payload carrying more precision than the column's declared type — a `Decimal` **truncates** at its scale (`1.005`, `1.006` and `1.009` all store as `1.00` in a `Decimal(10, 2)`), a `Float32` **rounds** to its nearest representable value (`16777217` stores as `16777216`) — and ClickHouse applies the same narrowing to a bound filter constant at compare time. The stream narrows **both operands** identically before comparing, so its verdict matches the query path's on narrowing columns under every operator: a `_gt: "1.004"` filter on a `Decimal(10, 2)` column withholds a `1.005` payload exactly as the query path hides the stored `1.00`. (An earlier revision of this feature compared the raw payload and could deliver such an event; that fail-open is closed, and an integration test holds every in-range numeric stream verdict equal to a live ClickHouse's — for the out-of-range operands the range gate refuses, it asserts the half that matters: the stream never admits a row ClickHouse hides.) What remains payload-vs-stored: an event whose insert later **fails outright** (an out-of-range value, a batch error, the DLQ) was already streamed to whichever subscribers the filter admitted, and its row never becomes queryable. -One more boundary is temporal: a subscriber's claims (and role) are captured when the SSE connection is established and are never re-read, while the policy itself is re-read on every event. Tightening a policy therefore applies from the next event, but a token that expires — or claims revoked at the identity provider — keeps its open stream until the client disconnects, so treat connection lifetime as the revocation window for stream row-scoping. +One more boundary is temporal: a subscriber's claims (and role) are captured when the SSE connection is established and are never re-read, while the policy itself is re-read on every live event. A gap-fill replay is the one exception: it runs under the single policy snapshot taken when the connection opened, so a policy change landing mid-replay applies from the first live event after it. Tightening a policy therefore applies from the next live event, but a token that expires — or claims revoked at the identity provider — keeps its open stream until the client disconnects, so treat connection lifetime as the revocation window for stream row-scoping. Each row withheld **from a subscriber** by row-level security is counted in `wavehouse_sse_rows_withheld_total` (labeled by table and role; a row withheld from three subscribers counts three times) — check it before concluding a quiet stream simply has no matching rows. diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index 0a1ee5b9..79bb5e5e 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -581,7 +581,7 @@ Each SSE connection is bound to a single `?table=`; to consume multiple tables, Row values of top-level `DateTime`/`DateTime64` columns inside `data` arrive in the canonical RFC 3339 UTC form (ingest rewrites them before publishing — see [timestamp canonicalization](#timestamp-canonicalization)), so a live event and a `/v1/query` read of the same row agree on the instant in zone-explicit form — a zone-less spelling no longer parses as local time in a browser ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)). The two renderings are byte-identical regardless of the declared time zone or a `Nullable` wrapper — a column declared with a non-UTC zone also streams as `Z`, and `/v1/query` normalizes it (nullable or not) to UTC before rendering. Canonicalization is fail-open at ingest, so a value outside the accepted input forms streams in whatever spelling the producer sent — and for exactly those events the byte-identity above does not hold: a spelling ClickHouse accepts anyway is stored and still queries back canonical, while one it too rejects lands in the DLQ and never becomes queryable at all. Events ingested before this behavior shipped likewise replay in their original spelling. -**Note:** When access control policies are active, streamed events are filtered per the caller's role: tables without `select` permission are skipped, denied columns are removed from each event, and the role's [row-level `filter`](/access-control#row-level-security) is evaluated per subscriber against the caller's JWT claims — supplied by the connection's token (the `Authorization` header, or the `?token=` fallback above), with replayed gap-fill events filtered the same way. For a filter constant the query path's SQL also accepts ([the enforcement caution](/access-control#where-each-rule-is-enforced) gives per-type guidance), a connection is never delivered a row the query path would hide for that role — every comparison the stream can't prove fails closed and withholds the row instead. Numeric comparisons run in the column's storage domain — both operands narrowed the way ClickHouse narrows the stored value and the bound constant — so columns that narrow on insert (`Float32`/`Float64` width, a `Decimal`'s scale) agree with the query path too; the residual payload-vs-stored case is an event whose insert later fails into the DLQ, which the caution documents. The connection's claims are captured once, when the stream is established — a policy change applies from the next event, but an expired token or changed claims take effect only when the client reconnects. +**Note:** When access control policies are active, streamed events are filtered per the caller's role: tables without `select` permission are skipped, denied columns are removed from each event, and the role's [row-level `filter`](/access-control#row-level-security) is evaluated per subscriber against the caller's JWT claims — supplied by the connection's token (the `Authorization` header, or the `?token=` fallback above), with replayed gap-fill events filtered the same way. For a filter constant the query path's SQL also accepts ([the enforcement caution](/access-control#where-each-rule-is-enforced) gives per-type guidance), a connection is never delivered a row the query path would hide for that role — every comparison the stream can't prove fails closed and withholds the row instead. Numeric comparisons run in the column's storage domain — both operands narrowed the way ClickHouse narrows the stored value and the bound constant — so columns that narrow on insert (`Float32`/`Float64` width, a `Decimal`'s scale) agree with the query path too; the residual payload-vs-stored case is an event whose insert later fails into the DLQ, which the caution documents. The connection's claims are captured once, when the stream is established — a policy change applies from the next live event (an in-flight gap-fill finishes under the policy snapshot taken when the stream opened), but an expired token or changed claims take effect only when the client reconnects. **CORS:** `/v1/stream` honors the `server.cors_allowed_origins` allowlist like every endpoint, so a browser `EventSource` from an allowed origin connects normally. `Last-Event-ID` is allow-listed in the CORS preflight so fetch-based clients can resume cross-origin. diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index b7379fdc..1d25cbf5 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -85,11 +85,11 @@ The API layer uses [Chi](https://github.com/go-chi/chi) for routing with Request The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)) lives next to the keepalive primitives it shares. One abstraction per file. -- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the type-aware comparison seeded from the schema registry — `policy.ColumnSpec`: numeric columns compare numerically, `String` bytewise, `DateTime`/`DateTime64` as instants through the same parser ingest canonicalization uses (`discovery.Column.TimeParser` — one grammar, so filter constants and canonicalized payloads can't disagree on the instant), everything else admits byte-equality only and fails ordering/`!=` closed, so a missing schema can never downgrade the comparison to a leak). Each row withheld this way increments `wavehouse_sse_rows_withheld_total`. This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayProjector` shares the same projection and per-connection row check for the handler's gap-fill, caching the per-table column-kind lookup across the replay loop. -- **subscriber.go** — `Subscriber`, the per-connection handle. It carries the connection's JWT claims, fixed at construction (`NewSubscriber(claims)`, no setter) — the claims the `Hub` resolves a role's row-level `filter` against, and immutability is what makes the fan-out's unsynchronized claims read race-free structurally. It owns a single ready-to-write outbound queue of `Frame`s (each tagged with its `kind`, so the handler labels the write where it happens): producers — the keepalive wheel and the event `Hub` — fan frames in with `Send` (non-blocking; a full queue drops and the `Hub` counts it), and the handler drains `Frames()` to the client verbatim. The queue is sized for buffering live events (cap 64, up from the keepalive-only cap 1; #152 will make it a knob), and an `Evicted()` channel is the seam the slow-consumer follow-up closes to disconnect a wedged consumer. -- **bucket.go** — `Bucket`, the reusable fan-out primitive: a concurrency-safe set of subscribers. `Push` delivers a shared `Frame` to each fire-and-forget (the keepalive wheel's ring); `Snapshot` exposes the members so the event `Hub` can fan out while inspecting each `Send` result (to count drops). The `Hub` holds one `Bucket` per `(topic, role)` so a projected frame is built once and sent to every member instead of re-projected per subscriber. +- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the type-aware comparison seeded from the schema registry — `policy.ColumnSpec`: numeric columns compare numerically, `String` bytewise, `DateTime`/`DateTime64` as instants through the same parser ingest canonicalization uses (`discovery.Column.TimeParser` — one grammar, so filter constants and canonicalized payloads can't disagree on the instant), everything else admits byte-equality only and fails ordering/`!=` closed, so a missing schema can never downgrade the comparison to a leak). Each row withheld this way increments `wavehouse_sse_rows_withheld_total`. This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayProjector` shares the same projection and per-connection row check for the handler's gap-fill, holding one policy snapshot per gap-fill and caching the per-table column-kind lookup across the replay loop. +- **subscriber.go** — `Subscriber`, the per-connection handle. It carries the connection's JWT claims, fixed at construction (`NewSubscriber(claims, metrics)`, no setter) — the claims the `Hub` resolves a role's row-level `filter` against, and immutability is what makes the fan-out's unsynchronized claims read race-free structurally. It owns a single ready-to-write outbound queue of `Frame`s (each tagged with its `kind`, so the handler labels the write where it happens): producers — the keepalive wheel and the event `Hub` — fan frames in with `Send` (non-blocking; a full queue drops, and `Send` itself counts the drop by frame kind, so no producer can forget to), and the handler drains `Frames()` to the client verbatim. The queue is sized for buffering live events (cap 64, up from the keepalive-only cap 1; #152 will make it a knob), and an `Evicted()` channel is the seam the slow-consumer follow-up closes to disconnect a wedged consumer. +- **bucket.go** — `Bucket`, the reusable fan-out primitive: a concurrency-safe set of subscribers. `Push` delivers a shared `Frame` to each fire-and-forget (the keepalive wheel's ring, and the `Hub`'s no-row-filter fast path); `Snapshot` exposes the members so the event `Hub` can evaluate row visibility per subscriber before sending (drop counting lives in `Send` itself). The `Hub` holds one `Bucket` per `(topic, role)` so a projected frame is built once and sent to every member instead of re-projected per subscriber. - **heartbeat.go** — The keepalive wheel (`Heartbeater`). A single process-wide ticker fans a minimal `:` comment across the ring of `Bucket`s, waking ~1/N of live streams per tick so the writes don't synchronize. The effective per-connection keepalive period is `stream.keepalive_interval` (the wheel ticks every `keepalive_interval ÷ keepalive_buckets`, so one rotation spans the interval); the owning handler goroutine does the actual write, so the shared ticker never touches a `ResponseWriter` directly. -- **metrics.go** — `Metrics`, the SSE instrument set: `wavehouse_sse_active_streams` (open streams), `wavehouse_sse_stream_duration_seconds` (lifetime), `wavehouse_sse_frames_sent_total` / `wavehouse_sse_bytes_sent_total` (labeled by `kind`: `keepalive`, `event`, `replay`), `wavehouse_sse_dropped_frames_total` (frames dropped to a full subscriber queue — the slow-consumer signal that was silent before #294), and `wavehouse_sse_rows_withheld_total` (rows withheld from a subscriber by the row-level-security filter, labeled by table and role — the signal that separates "no matching rows" from "a fail-closed filter is withholding everything"). Nil-safe, so the handler holds one unconditionally and tests skip wiring it; one shared instance records both the handler's write sites and the `Hub`'s drop and row-withheld counts. Separate from `observability.RegisterSystemMetrics`, which covers only the NATS/Pebble system gauges. Streams are observed through these metrics rather than per-event traces (the router excludes `/v1/stream` from the HTTP tracer). +- **metrics.go** — `Metrics`, the SSE instrument set: `wavehouse_sse_active_streams` (open streams), `wavehouse_sse_stream_duration_seconds` (lifetime), `wavehouse_sse_frames_sent_total` / `wavehouse_sse_bytes_sent_total` (labeled by `kind`: `keepalive`, `event`, `replay`), `wavehouse_sse_dropped_frames_total` (frames dropped to a full subscriber queue — the slow-consumer signal that was silent before #294), and `wavehouse_sse_rows_withheld_total` (rows withheld from a subscriber by the row-level-security filter, labeled by table and role — the signal that separates "no matching rows" from "a fail-closed filter is withholding everything"). Nil-safe, so the handler holds one unconditionally and tests skip wiring it; one shared instance records the handler's write sites, each `Subscriber`'s queue-full drops (counted inside `Send`, by frame kind), and the `Hub`'s row-withheld counts. Separate from `observability.RegisterSystemMetrics`, which covers only the NATS/Pebble system gauges. Streams are observed through these metrics rather than per-event traces (the router excludes `/v1/stream` from the HTTP tracer). ### `auth/` — Authentication @@ -141,7 +141,9 @@ The package's design invariants — stdout always 100%, WARN+ERROR always export ### `policy/` — Access Control - **policy.go** — Hasura-style policy types (`Policy`, `TablePolicy`, `RolePermissions`, `Filter`), `Evaluate()` function that resolves permissions against JWT claims (including `{{ jwt.claim.path }}` template resolution), the per-column decision `IsColumnAllowed()` plus its batch/projection forms `AllowedProjection()` and `RestrictsColumns()` (used to expand a `select_all` request into a role's allowed columns), `IsAggregationAllowed()`, `Validate()`. `Evaluate` resolves a role's row-`filter` **once** (`resolvePredicates`) and feeds both read surfaces from that single resolution — `predicatesToSQL` renders the query path's `WHERE`, and the same predicates ride on `ResolvedPermissions` for the stream's in-memory check — so row visibility can't drift between them (#319). -- **rowfilter.go** — the in-memory row-visibility twin of the SQL `WHERE`: `HasRowFilter`, `RowVisible` (evaluates the resolved predicates against a decoded event, per subscriber), and `ColumnSpec` — the per-column comparison contract (`ColumnKind` `Numeric`/`Text`/`Time`/`Opaque`, plus each kind's parameters: the caller-supplied instant parser for `Time`, the `NumericSpec` storage model for `Numeric`) whose zero value is the fail-closed floor: numeric columns compare in the column's **storage domain** (both operands rendered to the claim side's canonical decimal form, then narrowed the way ClickHouse narrows the stored value and the bound constant — `Float32`/`Float64` width rounding, `Decimal` scale truncation, integers exact at any width, with an operand outside the column's width or a `Decimal`'s precision budget refused rather than modeled; the `tests/integration` differential oracle holds in-range verdicts equal to a live ClickHouse's and asserts the never-admit-where-SQL-hides direction for the refused out-of-range operands), `String` bytewise, `DateTime`/`DateTime64` chronologically (both operands through the ingest grammar; either side unreadable ⇒ withheld), and everything else (including any column with no usable schema) admits byte-equality only, failing `!=`/`>`/`<` closed. +- **rowfilter.go** — the in-memory row-visibility twin of the SQL `WHERE`: `HasRowFilter`, `RowVisible` (evaluates the resolved predicates against a decoded event, per subscriber), and `ColumnSpec` — the per-column comparison contract (`ColumnKind` `Numeric`/`Text`/`Time`/`Opaque`, plus each kind's parameters: the caller-supplied instant parser for `Time`, the `NumericSpec` storage model for `Numeric`) whose zero value is the fail-closed floor: numeric columns compare in the column's **storage domain** (operands rendered by canonical.go, compared by numeric.go — next two bullets), `String` bytewise, `DateTime`/`DateTime64` chronologically (both operands through the ingest grammar; either side unreadable ⇒ withheld), and everything else (including any column with no usable schema) admits byte-equality only, failing `!=`/`>`/`<` closed. +- **canonical.go** — the one rendering layer for comparison operands: every value a `filter` or `check` compares — a JWT claim (`CanonicalScalar`), a policy-authored literal (`CanonicalNumericLiteral`), an ingested payload value (`numericCanonical`) — converges on one exact canonical decimal form (positional, digit-bounded, never a float64 round-trip), so what a read filter binds and what the stream compares can't drift; `scalarString` is the deliberate exception, the raw byte rendering that `Text`/`Opaque` equality compares. +- **numeric.go** — compares canonical forms the way the column that stores them would: `compareCanonicalDecimals` orders by exact digit-string arithmetic, and `NumericSpec` first narrows both operands the way ClickHouse narrows the stored value and the bound constant — `Float32`/`Float64` width rounding, `Decimal` scale truncation, integers exact at any width, with an operand outside the column's width or a `Decimal`'s precision budget refused rather than modeled; the `tests/integration` differential oracle holds in-range verdicts equal to a live ClickHouse's and asserts the never-admit-where-SQL-hides direction for the refused out-of-range operands. - **store.go** — `Store` backed by NATS KV bucket `WAVEHOUSE_POLICY`. Supports file-based bootstrap (YAML/JSON), cluster-wide sync via KV Watch, local caching. ### `pipes/` — Named Query Pipes diff --git a/docs/src/content/docs/sdk/streaming.md b/docs/src/content/docs/sdk/streaming.md index 9403bc12..ae75721d 100644 --- a/docs/src/content/docs/sdk/streaming.md +++ b/docs/src/content/docs/sdk/streaming.md @@ -110,7 +110,7 @@ The SDK warns when more than 5 concurrent SSE connections are open (browser limi ### Server-Side Policy Filtering -Access-control policy applies on the server before anything reaches the client: tables the connection's role can't `select` are skipped, denied columns are stripped from each event, and the role's row-level `filter` is evaluated per subscriber against the connection's JWT claims. A stream on a row-policied table therefore delivers only the rows the policy admits for that connection — and, where the server's in-memory comparison can't prove a match, fewer; see [Access control — where each rule is enforced](/access-control#where-each-rule-is-enforced). Claims are captured when the connection opens: a policy change applies from the next event, while token expiry or claim changes take effect on reconnect. +Access-control policy applies on the server before anything reaches the client: tables the connection's role can't `select` are skipped, denied columns are stripped from each event, and the role's row-level `filter` is evaluated per subscriber against the connection's JWT claims. A stream on a row-policied table therefore delivers only the rows the policy admits for that connection — and, where the server's in-memory comparison can't prove a match, fewer; see [Access control — where each rule is enforced](/access-control#where-each-rule-is-enforced). Claims are captured when the connection opens: a policy change applies from the next live event (an in-flight gap-fill replay finishes under the policy snapshot taken at connect), while token expiry or claim changes take effect on reconnect. ### Client-Side Stream Filtering diff --git a/internal/api/stream.go b/internal/api/stream.go index caf6acc8..5cf299aa 100644 --- a/internal/api/stream.go +++ b/internal/api/stream.go @@ -81,7 +81,7 @@ func (h *StreamHandler) Handle(w http.ResponseWriter, r *http.Request) { // Register for live events before gap-fill so events arriving during replay // buffer in the subscriber queue instead of being missed (an overlap with // replay yields duplicates, deduped client-side by id: — at-least-once). - sub := stream.NewSubscriber(claims) + sub := stream.NewSubscriber(claims, h.Metrics) h.Hub.Add(topic, role, sub) defer h.Hub.Remove(topic, role, sub) diff --git a/internal/policy/canonical.go b/internal/policy/canonical.go new file mode 100644 index 00000000..1f91776f --- /dev/null +++ b/internal/policy/canonical.go @@ -0,0 +1,273 @@ +package policy + +import ( + "encoding/json" + "fmt" + "math" + "math/big" + "strconv" + "strings" +) + +// This file is the policy engine's ONE rendering layer for comparison operands: +// every value a row-filter or insert-check compares — a JWT claim, a +// policy-authored literal, an ingested payload value — is rendered here before +// any comparison or SQL bind, so the two read surfaces can't disagree on what a +// value "is". Three entry points, one per operand source: +// +// CanonicalScalar — decoded claim values (Evaluate's template resolution) +// and the insert-check comparison's two sides (internal/api) +// CanonicalNumericLiteral — policy-authored literals ("1.0" → "1"), behind the +// json.Valid grammar gate +// numericCanonical — ingested payload values on the stream's row-filter +// path (string / json.Number / float64), routed through +// the two above +// +// All three converge on one canonical decimal form (canonicalDecimal, bounded +// by maxCanonicalDigits): exact at any width, positional (never an exponent — +// "1e-3" renders as "0.001"), no leading/trailing zeros, "-0" folded to "0". +// Those invariants are what numeric.go's digit-string comparison +// (compareCanonicalDecimals) and storage-domain narrowing (NumericSpec.compare) +// rely on. scalarString is the deliberate exception: the raw byte rendering for +// ColumnText/ColumnOpaque comparison, where the payload's own spelling IS the +// compared value. + +// maxCanonicalDigits bounds both a numeric literal's digit count and its +// exact decimal expansion. Big-integer parsing is superlinear in digit count +// and the ingest check path hands CanonicalScalar client-controlled literals +// (CWE-400), and a short exponent literal can hide a wide expansion ("1e-150" +// is six characters with a 152-character exact form). 100 digits is far past +// any real id — uint256 is 78. +const maxCanonicalDigits = 100 + +// maxNumericOperandChars is the O(1) length pre-gate on numeric comparison +// operands, checked before ANY scan of the value. It is verdict-preserving: a +// JSON number literal carries at most four non-digit bytes (a sign, a decimal +// point, an exponent marker and its sign), so anything longer already fails +// the canonical digit bound (maxCanonicalDigits) — but proving that inside the +// canonical gate costs a full json.Valid pass plus a digit count over a +// client-controlled value, per subscriber per event on the fan-out goroutine. +// The gate refuses first, without reading the bytes. +const maxNumericOperandChars = maxCanonicalDigits + 4 + +// CanonicalScalar renders a decoded JSON value as the canonical string the +// policy layer binds and compares, reporting ok=false for values with no such +// form: null, objects, and arrays. A structured value is never a sensible +// scalar comparison value — it usually means a dropped path segment +// ({{ jwt.app_metadata }} for {{ jwt.app_metadata.tenant_id }}), and binding +// fmt.Sprint's "map[…]"/"[…]" rendering would let _neq/_lt match essentially +// every row; the one legitimate structured shape, a bare-claim _in array, is +// unpacked by resolveInValues before its elements reach here. A json.Number +// (jwt.WithJSONNumber on claims, UseNumber on ingest payloads) binds in +// canonical decimal form, not the token's spelling: "1", "1.0", and "1e3" are +// one JSON value, and a numeric ClickHouse column rejects '1.0'/'1e3' as a +// per-query TYPE_MISMATCH error. The canonical form is exact at every width +// and precision — integer literals via big.Int, fractions and exponents via +// canonicalDecimal, never a float64 round-trip that could bind a value the +// token doesn't carry ("1e-400" fails closed rather than collapsing to "0"). +// A literal, or an exact form, past maxCanonicalDigits likewise has no +// canonical form and fails closed (1e400, 1e-400). Claim resolution and the +// insert-check comparison's two sides (internal/api) all route through this +// one function, so what a read filter binds and what a write check accepts +// can't drift. +func CanonicalScalar(v any) (string, bool) { + switch val := v.(type) { + case nil, map[string]any, []any: + return "", false + case json.Number: + // The digit bound guards the superlinear big.Int parse on the + // client-controlled ingest path. It counts digits, not bytes — a sign, + // point, or exponent marker doesn't feed the big parse — so "-1e99" + // binds exactly like its written-out form, matching the "one JSON + // value" contract below (only at the bound's very edge can the exact-form + // gate's slack separate two spellings). canonicalDecimal re-checks its + // own exact-form length, where a short literal can hide a wide expansion. + digits := 0 + for i := 0; i < len(val); i++ { + if val[i] >= '0' && val[i] <= '9' { + digits++ + } + } + if digits > maxCanonicalDigits { + return "", false + } + if i, ok := new(big.Int).SetString(val.String(), 10); ok { + return i.String(), true + } + return canonicalDecimal(val.String()) + case float64: + // Production claims arrive as json.Number (jwt.WithJSONNumber), but + // Evaluate is an exported API and float64 is what a plain json.Unmarshal + // hands any other caller. At or past 2^53 the decode has already + // collapsed neighboring JSON integers onto one float — ANY digits + // rendered for it could be another principal's ID — so refuse, in depth + // (#381 review). Below that, render positionally: fmt.Sprint's exponent + // form ("1e+06") is a spelling numeric ClickHouse columns reject. + if math.Abs(val) >= 1<<53 { + return "", false + } + return strconv.FormatFloat(val, 'f', -1, 64), true + default: + return fmt.Sprint(val), true + } +} + +// LiteralValue marks an insert-check required value the policy author wrote +// as a placeholder-free literal (Evaluate). A literal carries no JSON type — +// "1.0" means the number 1 to a numeric column and the three-character text +// to a String column — so the check comparison (internal/api) accepts its +// numeric reading as well as its spelling. The type is the gate: a +// claim-derived value is never wrapped, so a string-typed claim keeps strict +// canonical equality and can't gain a numeric reading it didn't have. Only +// CheckClauses carries this type; read filters bind plain strings. +type LiteralValue string + +// CanonicalNumericLiteral renders a policy-authored literal that spells a +// JSON number in canonical decimal form ("1.0" → "1"), reporting ok=false for +// everything else. The json.Valid gate keeps this to spellings JSON itself +// can produce: big.Int would also take "+5" or "007", readings no decoded +// claim or payload value ever has. It canonicalizes nothing at resolve time — +// the literal still binds and auto-injects exactly as written; only the check +// comparison consults this second reading. +func CanonicalNumericLiteral(s string) (string, bool) { + if !json.Valid([]byte(s)) { + return "", false + } + return CanonicalScalar(json.Number(s)) +} + +// canonicalDecimal renders a non-integer JSON number literal (one carrying a +// fraction or exponent) as its exact canonical decimal string: "1.0" → "1", +// "2.50" → "2.5", "1e3" → "1000", "25e-4" → "0.0025", every digit preserved +// at any precision. ok=false for anything that is not a JSON number and for +// any literal whose exact decimal form would exceed maxCanonicalDigits. +// Exactness is the point: rounding through float64 would collapse "1e-400" +// to "0" and land wide decimals on their neighbors, either way binding a +// value the token doesn't carry. +func canonicalDecimal(lit string) (string, bool) { + mant, expStr, hasExp := strings.Cut(lit, "e") + if !hasExp { + mant, expStr, hasExp = strings.Cut(lit, "E") + } + exp := 0 + if hasExp { + var err error + // Atoi rejects an empty or non-numeric exponent. The magnitude bound is + // the allocation guard, not a redundancy: the exact-form length check at + // the bottom runs only after strings.Repeat has already built the + // string, so without this bound a 12-byte "1e1000000000" would allocate + // a ~1 GiB expansion before being rejected. + if exp, err = strconv.Atoi(expStr); err != nil || exp > 2*maxCanonicalDigits || exp < -2*maxCanonicalDigits { + return "", false + } + } + sign := "" + if strings.HasPrefix(mant, "-") { + sign, mant = "-", mant[1:] + } + intPart, fracPart, _ := strings.Cut(mant, ".") + digits := intPart + fracPart + if digits == "" { + return "", false + } + for i := 0; i < len(digits); i++ { + if digits[i] < '0' || digits[i] > '9' { + return "", false + } + } + // point is where the decimal point sits in digits once the exponent is + // applied: digits[:point] to its left, digits[point:] to its right. + point := len(intPart) + exp + for len(digits) > 0 && digits[0] == '0' { + digits = digits[1:] + point-- + } + for len(digits) > 0 && len(digits) > point && digits[len(digits)-1] == '0' { + digits = digits[:len(digits)-1] + } + if digits == "" { + // Every spelling of zero ("0.0", "-0e9") is the one value 0 — + // signless, since a numeric column can't parse "-0". + return "0", true + } + var out string + switch { + case point <= 0: + out = "0." + strings.Repeat("0", -point) + digits + case point >= len(digits): + out = digits + strings.Repeat("0", point-len(digits)) + default: + out = digits[:point] + "." + digits[point:] + } + // The exact form is what must stay small: "1e-150" is a six-character + // literal with a 152-character expansion. The +2 slack exists for a "0." + // prefix, though any form may spend it (a 102-digit integer expansion + // passes). This runs after the Repeat above, so it bounds what callers + // see — the exponent bound is what keeps the allocation itself small. + if len(out) > maxCanonicalDigits+2 { + return "", false + } + return sign + out, true +} + +// numericCanonical renders a payload value as the canonical decimal form the +// numeric comparison consumes, ok=false for anything that is not a number a +// ClickHouse numeric column could have stored: booleans, structured values and +// null, spellings outside the JSON number grammar ("Inf", "NaN", "0x1f", +// "007"), values whose exact digits were lost upstream (float64 at/past 2^53), +// and anything past the canonical digit bound. String and json.Number inputs +// take the claim side's own gates (CanonicalNumericLiteral / CanonicalScalar), +// so the payload and constant sides can never disagree on what counts as a +// number or how it is spelled. +func numericCanonical(v any) (string, bool) { + switch x := v.(type) { + case string: + if len(x) > maxNumericOperandChars { + return "", false + } + return CanonicalNumericLiteral(x) + case json.Number: + if len(x) > maxNumericOperandChars { + return "", false + } + return CanonicalScalar(x) + case float64: + // CanonicalScalar applies the 2^53 exactness guard and renders + // positionally; the literal gate then re-canonicalizes the one + // rendering FormatFloat emits that canonical form forbids ("-0"). + s, ok := CanonicalScalar(x) + if !ok { + return "", false + } + return CanonicalNumericLiteral(s) + default: + return "", false + } +} + +// scalarString renders a JSON-decoded scalar as the exact BYTES compared under +// ColumnText and ColumnOpaque — deliberately the payload's raw spelling, never +// a canonical form: a String column stores the payload text verbatim, so byte +// comparison against it must use that spelling (canonicalizing "1.0" to "1" +// here would move equality away from what ClickHouse stores). Numeric coercion +// deliberately does NOT live here — the ColumnNumeric arm routes both operands +// through the claim side's canonical machinery (numericCanonical). Non-scalars +// (arrays, objects, null) return ok=false so the predicate fails closed rather +// than guessing. The float64 case serves callers that decoded without +// UseNumber (the stream itself always does); -1 precision emits the shortest +// round-trip form without an exponent, so integer IDs read back as "123", not +// "1.23e+02". +func scalarString(v any) (string, bool) { + switch x := v.(type) { + case string: + return x, true + case json.Number: + return string(x), true + case float64: + return strconv.FormatFloat(x, 'f', -1, 64), true + case bool: + return strconv.FormatBool(x), true + default: + return "", false + } +} diff --git a/internal/policy/numeric.go b/internal/policy/numeric.go new file mode 100644 index 00000000..1a27017b --- /dev/null +++ b/internal/policy/numeric.go @@ -0,0 +1,260 @@ +package policy + +import ( + "math/big" + "strconv" + "strings" +) + +// This file compares canonical decimal forms (canonical.go's output) the way +// the column that stores them would: compareCanonicalDecimals is the exact +// digit-string ordering, and NumericSpec narrows both operands into the +// column's STORAGE domain first — the same narrowing ClickHouse applies to the +// stored value at insert and to the filter constant at compare — so the +// stream's in-memory verdict can't drift from the query path's SQL verdict. + +// NumericFamily classifies how a ClickHouse numeric column stores a value — +// the narrowing the row-filter comparison must apply to BOTH operands so its +// verdict matches the query path, where ClickHouse narrows the stored value at +// insert AND the filter constant at compare. The zero value is NumericNone: no +// storage model, every comparison refused — the same fail-closed zero-value +// contract as ColumnOpaque, so a future numeric type nobody classified can +// never be compared under the wrong model. +type NumericFamily uint8 + +const ( + NumericNone NumericFamily = iota // unclassified: refuse, fail closed + NumericInteger // Int*/UInt*: exact at any width + NumericFloat // Float32/Float64: IEEE rounding at Bits + NumericDecimal // Decimal*: truncation at Scale +) + +// NumericSpec is a numeric column's storage model. Bits is the bit width +// (float width for NumericFloat, integer width for NumericInteger); Unsigned +// marks UInt* (NumericInteger only); Precision and Scale are the stored total +// and fractional digit counts (NumericDecimal only, 1 ≤ Precision ≤ 76). +type NumericSpec struct { + Family NumericFamily + Bits int + Unsigned bool + Precision int + Scale int +} + +// intBounds holds each ClickHouse integer width's inclusive decimal bounds as +// canonical-form strings, computed once. A constant outside the width is not +// reliably modelable — on one and the same release, ClickHouse was measured to +// ERROR the comparison (a negative literal against an unsigned column: the +// role reads no rows), to PROMOTE and compare mathematically ('256' against a +// UInt8), and to WRAP at a width boundary ('9223372036854775808' against an +// Int64 compares as −2^63, where exact-precision comparison would ADMIT the +// −2^63 rows SQL hides under !=). Refusing out-of-range operands is the one +// rule safe under all three behaviors; the cost is availability on bounds no +// in-range data could ever satisfy differently. +var intBounds = func() map[int]struct{ sMin, sMax, uMax string } { + m := make(map[int]struct{ sMin, sMax, uMax string }, 6) + for _, bits := range []int{8, 16, 32, 64, 128, 256} { + // big.Int because Int128/Int256 exceed every native width; rendered + // once to decimal strings so range checks are compareCanonicalDecimals + // calls. For bits=8 the three bounds are −128, 127, and 255. + pow := new(big.Int).Lsh(big.NewInt(1), uint(bits-1)) // 2^(bits−1) + sMin := new(big.Int).Neg(pow) // signed min: −2^(bits−1) + sMax := new(big.Int).Sub(pow, big.NewInt(1)) // signed max: 2^(bits−1) − 1 + uMax := new(big.Int).Sub(new(big.Int).Lsh(pow, 1), big.NewInt(1)) // unsigned max: 2^bits − 1 + m[bits] = struct{ sMin, sMax, uMax string }{sMin.String(), sMax.String(), uMax.String()} + } + return m +}() + +// integerInRange reports whether a canonical integer form lies within the +// column's width. An unknown width refuses — fail closed, never a guessed +// range. Integers alone need this explicit bounds table because their +// comparison is digit-string arithmetic with no inherent width; the float +// family's range gate is narrowFloat's ParseFloat-overflow refusal, and the +// decimal family's is decimalInPrecision. +func (n NumericSpec) integerInRange(c string) bool { + b, ok := intBounds[n.Bits] + if !ok { + return false + } + if n.Unsigned { + return compareCanonicalDecimals(c, "0") >= 0 && compareCanonicalDecimals(c, b.uMax) <= 0 + } + return compareCanonicalDecimals(c, b.sMin) >= 0 && compareCanonicalDecimals(c, b.sMax) <= 0 +} + +// decimalInPrecision reports whether a canonical form's integer digits fit the +// column's Precision−Scale budget. A payload past it is never storable +// (DECIMAL_OVERFLOW rejects the insert); a constant past it was measured to +// promote and compare mathematically on the query path — but the integer +// widths' wrap behavior (intBounds) shows the same class is not reliably +// modelable across pairs, so the refusal keeps one rule for every family at an +// availability-only cost. A lone "0" integer part spends no digits +// (Decimal(2,2) legally stores 0.99). +func (n NumericSpec) decimalInPrecision(c string) bool { + if n.Precision < 1 || n.Scale < 0 || n.Scale > n.Precision { + // No coherent model: refuse, fail closed. The Scale bounds also protect + // truncateScale's slicing — a hand-built spec must degrade to refusal, + // never a panic on the fan-out goroutine. + return false + } + intPart, _, _ := strings.Cut(strings.TrimPrefix(c, "-"), ".") + digits := len(intPart) + if intPart == "0" { + digits = 0 + } + return digits <= n.Precision-n.Scale +} + +// compare orders two canonical decimal operands (numericCanonical / +// CanonicalNumericLiteral output) in the column's storage domain, ok=false when +// the model refuses the pair. Narrowing BOTH sides is what ClickHouse itself +// does — it narrows the payload at insert and the bound constant at compare +// (verified: Float32 stores 16777217 as 16777216 and `= '16777217'` still +// matches; Decimal(10,2) stores 1.005 as 1.00 and `= '1.005'` still matches) — +// so a threshold filter can no longer admit an event whose stored row lands on +// the other side of the comparison (the ordering fail-open raised on #381). +func (n NumericSpec) compare(a, b string) (int, bool) { + switch n.Family { + case NumericInteger: + // A fractional CONSTANT against an integer column is a per-query type + // error on the SQL path (the role reads no rows, loudly); a fractional + // PAYLOAD was never storable in the column. Refuse both — fail closed. + if strings.Contains(a, ".") || strings.Contains(b, ".") { + return 0, false + } + // Same rule for the column's range: ClickHouse's reading of an + // out-of-range constant varies by pair (error, mathematical promotion, + // or a width-boundary wrap that compares against a DIFFERENT value + // than written — see intBounds), and an out-of-range payload was never + // storable. Refuse both sides rather than model any one behavior. + if !n.integerInRange(a) || !n.integerInRange(b) { + return 0, false + } + return compareCanonicalDecimals(a, b), true + case NumericFloat: + fa, ok := narrowFloat(a, n.Bits) + if !ok { + return 0, false + } + fb, ok := narrowFloat(b, n.Bits) + if !ok { + return 0, false + } + // Both operands are now exact values of the column's float domain, so + // direct comparison IS the domain comparison — no ties left to break. + switch { + case fa < fb: + return -1, true + case fa > fb: + return 1, true + default: + return 0, true + } + case NumericDecimal: + // Precision is the range gate of the decimal family: a payload with + // integer digits past Precision−Scale is never storable (the insert is + // rejected), while a constant past it was measured to PROMOTE on the + // query path and compare mathematically — the refusal there is an + // accepted availability cost, taken because the integer widths' wrap + // behavior proves this class has no reliable single model (see + // decimalInPrecision — one story across the three sites). Scale + // truncation below cannot change integer digits, so gating + // pre-truncation is exact. + if !n.decimalInPrecision(a) || !n.decimalInPrecision(b) { + return 0, false + } + return compareCanonicalDecimals(truncateScale(a, n.Scale), truncateScale(b, n.Scale)), true + case NumericNone: + return 0, false // no storage model: refuse, fail closed + default: + return 0, false // future family nobody taught this switch: same refusal + } +} + +// narrowFloat converts a canonical decimal form to the column's float domain +// with a single correct rounding (ParseFloat at the exact bit width — never a +// float64 detour, whose double rounding can land Float32 values one ULP off). +// A magnitude the domain can't hold refuses the comparison (ParseFloat reports +// the overflow as an error — the float family's range gate): ClickHouse would +// store ±Inf there, and matching infinities is a verdict this evaluator can't +// prove cheaply, so the row is withheld — availability, never exposure. An +// unknown width refuses too: ParseFloat silently treats any other bitSize as +// 64, which would compare a Float32 column in the wrong (wider) domain — the +// fail-open direction — instead of the zero-value-refuses contract the +// integer and decimal families keep. +func narrowFloat(canonical string, bits int) (float64, bool) { + if bits != 32 && bits != 64 { + return 0, false + } + f, err := strconv.ParseFloat(canonical, bits) + if err != nil { + return 0, false + } + return f, true +} + +// truncateScale narrows a canonical decimal form to scale fractional digits, +// truncating toward zero — ClickHouse's Decimal cast (1.005, 1.006 and 1.009 +// all store as 1.00 in a Decimal(10,2); rounding would predict 1.01). The +// result is re-canonicalized (trailing zeros trimmed, bare "-0" folded) so it +// stays valid compareCanonicalDecimals input. +func truncateScale(canonical string, scale int) string { + intPart, frac, hasFrac := strings.Cut(canonical, ".") + if !hasFrac { + return canonical + } + if len(frac) > scale { + frac = frac[:scale] + } + for len(frac) > 0 && frac[len(frac)-1] == '0' { + frac = frac[:len(frac)-1] + } + if len(frac) > 0 { + return intPart + "." + frac + } + if intPart == "-0" { + return "0" + } + return intPart +} + +// compareCanonicalDecimals orders two canonical decimal forms (CanonicalScalar +// output) as numbers, by digit-string arithmetic alone — the comparison twin of +// canonicalDecimal, sharing its invariants: an optional leading '-' (never on +// zero), no leading integer zeros except a lone "0", no trailing fraction +// zeros, no exponent. Those invariants are what make the string operations +// sound: with no leading zeros a longer integer part IS the larger magnitude, +// and with no trailing zeros a fraction that is a proper prefix of another IS +// the smaller. Never a float round-trip, so 64-bit-plus IDs order exactly. +func compareCanonicalDecimals(a, b string) int { + if a == b { + return 0 + } + na, nb := strings.HasPrefix(a, "-"), strings.HasPrefix(b, "-") + switch { + case na && !nb: + return -1 + case !na && nb: + return 1 + case na && nb: + return -compareCanonicalMagnitudes(a[1:], b[1:]) + } + return compareCanonicalMagnitudes(a, b) +} + +// compareCanonicalMagnitudes orders two unsigned canonical forms. +func compareCanonicalMagnitudes(a, b string) int { + ai, af, _ := strings.Cut(a, ".") + bi, bf, _ := strings.Cut(b, ".") + if len(ai) != len(bi) { + if len(ai) < len(bi) { + return -1 + } + return 1 + } + if c := strings.Compare(ai, bi); c != 0 { + return c + } + return strings.Compare(af, bf) +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 9c2747ad..1a47314d 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -1,12 +1,8 @@ package policy import ( - "encoding/json" "fmt" - "math" - "math/big" "regexp" - "strconv" "strings" "github.com/Wave-RF/WaveHouse/internal/chsql" @@ -421,214 +417,6 @@ func resolveInValues(tmpl string, claims map[string]any) []any { return nil } -// maxCanonicalDigits bounds both a numeric literal's digit count and its -// exact decimal expansion. Big-integer parsing is superlinear in digit count -// and the ingest check path hands CanonicalScalar client-controlled literals -// (CWE-400), and a short exponent literal can hide a wide expansion ("1e-150" -// is six characters with a 152-character exact form). 100 digits is far past -// any real id — uint256 is 78. -const maxCanonicalDigits = 100 - -// CanonicalScalar renders a decoded JSON value as the canonical string the -// policy layer binds and compares, reporting ok=false for values with no such -// form: null, objects, and arrays. A structured value is never a sensible -// scalar comparison value — it usually means a dropped path segment -// ({{ jwt.app_metadata }} for {{ jwt.app_metadata.tenant_id }}), and binding -// fmt.Sprint's "map[…]"/"[…]" rendering would let _neq/_lt match essentially -// every row; the one legitimate structured shape, a bare-claim _in array, is -// unpacked by resolveInValues before its elements reach here. A json.Number -// (jwt.WithJSONNumber on claims, UseNumber on ingest payloads) binds in -// canonical decimal form, not the token's spelling: "1", "1.0", and "1e3" are -// one JSON value, and a numeric ClickHouse column rejects '1.0'/'1e3' as a -// per-query TYPE_MISMATCH error. The canonical form is exact at every width -// and precision — integer literals via big.Int, fractions and exponents via -// canonicalDecimal, never a float64 round-trip that could bind a value the -// token doesn't carry ("1e-400" fails closed rather than collapsing to "0"). -// A literal, or an exact form, past maxCanonicalDigits likewise has no -// canonical form and fails closed (1e400, 1e-400). Claim resolution and the -// insert-check comparison's two sides (internal/api) all route through this -// one function, so what a read filter binds and what a write check accepts -// can't drift. -func CanonicalScalar(v any) (string, bool) { - switch val := v.(type) { - case nil, map[string]any, []any: - return "", false - case json.Number: - // The digit bound guards the superlinear big.Int parse on the - // client-controlled ingest path. It counts digits, not bytes — a sign, - // point, or exponent marker doesn't feed the big parse — so "-1e99" - // binds exactly like its written-out form, matching the "one JSON - // value" contract below (only at the bound's very edge can the exact-form - // gate's slack separate two spellings). canonicalDecimal re-checks its - // own exact-form length, where a short literal can hide a wide expansion. - digits := 0 - for i := 0; i < len(val); i++ { - if val[i] >= '0' && val[i] <= '9' { - digits++ - } - } - if digits > maxCanonicalDigits { - return "", false - } - if i, ok := new(big.Int).SetString(val.String(), 10); ok { - return i.String(), true - } - return canonicalDecimal(val.String()) - case float64: - // Production claims arrive as json.Number (jwt.WithJSONNumber), but - // Evaluate is an exported API and float64 is what a plain json.Unmarshal - // hands any other caller. At or past 2^53 the decode has already - // collapsed neighboring JSON integers onto one float — ANY digits - // rendered for it could be another principal's ID — so refuse, in depth - // (#381 review). Below that, render positionally: fmt.Sprint's exponent - // form ("1e+06") is a spelling numeric ClickHouse columns reject. - if math.Abs(val) >= 1<<53 { - return "", false - } - return strconv.FormatFloat(val, 'f', -1, 64), true - default: - return fmt.Sprint(val), true - } -} - -// LiteralValue marks an insert-check required value the policy author wrote -// as a placeholder-free literal (Evaluate). A literal carries no JSON type — -// "1.0" means the number 1 to a numeric column and the three-character text -// to a String column — so the check comparison (internal/api) accepts its -// numeric reading as well as its spelling. The type is the gate: a -// claim-derived value is never wrapped, so a string-typed claim keeps strict -// canonical equality and can't gain a numeric reading it didn't have. Only -// CheckClauses carries this type; read filters bind plain strings. -type LiteralValue string - -// CanonicalNumericLiteral renders a policy-authored literal that spells a -// JSON number in canonical decimal form ("1.0" → "1"), reporting ok=false for -// everything else. The json.Valid gate keeps this to spellings JSON itself -// can produce: big.Int would also take "+5" or "007", readings no decoded -// claim or payload value ever has. It canonicalizes nothing at resolve time — -// the literal still binds and auto-injects exactly as written; only the check -// comparison consults this second reading. -func CanonicalNumericLiteral(s string) (string, bool) { - if !json.Valid([]byte(s)) { - return "", false - } - return CanonicalScalar(json.Number(s)) -} - -// canonicalDecimal renders a non-integer JSON number literal (one carrying a -// fraction or exponent) as its exact canonical decimal string: "1.0" → "1", -// "2.50" → "2.5", "1e3" → "1000", "25e-4" → "0.0025", every digit preserved -// at any precision. ok=false for anything that is not a JSON number and for -// any literal whose exact decimal form would exceed maxCanonicalDigits. -// Exactness is the point: rounding through float64 would collapse "1e-400" -// to "0" and land wide decimals on their neighbors, either way binding a -// value the token doesn't carry. -func canonicalDecimal(lit string) (string, bool) { - mant, expStr, hasExp := strings.Cut(lit, "e") - if !hasExp { - mant, expStr, hasExp = strings.Cut(lit, "E") - } - exp := 0 - if hasExp { - var err error - // Atoi rejects an empty or non-numeric exponent. The magnitude bound is - // the allocation guard, not a redundancy: the exact-form length check at - // the bottom runs only after strings.Repeat has already built the - // string, so without this bound a 12-byte "1e1000000000" would allocate - // a ~1 GiB expansion before being rejected. - if exp, err = strconv.Atoi(expStr); err != nil || exp > 2*maxCanonicalDigits || exp < -2*maxCanonicalDigits { - return "", false - } - } - sign := "" - if strings.HasPrefix(mant, "-") { - sign, mant = "-", mant[1:] - } - intPart, fracPart, _ := strings.Cut(mant, ".") - digits := intPart + fracPart - if digits == "" { - return "", false - } - for i := 0; i < len(digits); i++ { - if digits[i] < '0' || digits[i] > '9' { - return "", false - } - } - // point is where the decimal point sits in digits once the exponent is - // applied: digits[:point] to its left, digits[point:] to its right. - point := len(intPart) + exp - for len(digits) > 0 && digits[0] == '0' { - digits = digits[1:] - point-- - } - for len(digits) > 0 && len(digits) > point && digits[len(digits)-1] == '0' { - digits = digits[:len(digits)-1] - } - if digits == "" { - // Every spelling of zero ("0.0", "-0e9") is the one value 0 — - // signless, since a numeric column can't parse "-0". - return "0", true - } - var out string - switch { - case point <= 0: - out = "0." + strings.Repeat("0", -point) + digits - case point >= len(digits): - out = digits + strings.Repeat("0", point-len(digits)) - default: - out = digits[:point] + "." + digits[point:] - } - // The exact form is what must stay small: "1e-150" is a six-character - // literal with a 152-character expansion. The +2 slack exists for a "0." - // prefix, though any form may spend it (a 102-digit integer expansion - // passes). This runs after the Repeat above, so it bounds what callers - // see — the exponent bound is what keeps the allocation itself small. - if len(out) > maxCanonicalDigits+2 { - return "", false - } - return sign + out, true -} - -// compareCanonicalDecimals orders two canonical decimal forms (CanonicalScalar -// output) as numbers, by digit-string arithmetic alone — the comparison twin of -// canonicalDecimal, sharing its invariants: an optional leading '-' (never on -// zero), no leading integer zeros except a lone "0", no trailing fraction -// zeros, no exponent. Those invariants are what make the string operations -// sound: with no leading zeros a longer integer part IS the larger magnitude, -// and with no trailing zeros a fraction that is a proper prefix of another IS -// the smaller. Never a float round-trip, so 64-bit-plus IDs order exactly. -func compareCanonicalDecimals(a, b string) int { - if a == b { - return 0 - } - na, nb := strings.HasPrefix(a, "-"), strings.HasPrefix(b, "-") - switch { - case na && !nb: - return -1 - case !na && nb: - return 1 - case na && nb: - return -compareCanonicalMagnitudes(a[1:], b[1:]) - } - return compareCanonicalMagnitudes(a, b) -} - -// compareCanonicalMagnitudes orders two unsigned canonical forms. -func compareCanonicalMagnitudes(a, b string) int { - ai, af, _ := strings.Cut(a, ".") - bi, bf, _ := strings.Cut(b, ".") - if len(ai) != len(bi) { - if len(ai) < len(bi) { - return -1 - } - return 1 - } - if c := strings.Compare(ai, bi); c != 0 { - return c - } - return strings.Compare(af, bf) -} - // navigateClaims traverses nested claim maps using dot-separated path parts. func navigateClaims(claims map[string]any, parts []string) any { if len(parts) == 0 || claims == nil { diff --git a/internal/policy/rowfilter.go b/internal/policy/rowfilter.go index eb6c1638..3e936b90 100644 --- a/internal/policy/rowfilter.go +++ b/internal/policy/rowfilter.go @@ -2,8 +2,6 @@ package policy import ( "encoding/json" - "math/big" - "strconv" "strings" "time" ) @@ -17,16 +15,6 @@ func (p *ResolvedPermissions) HasRowFilter() bool { return p != nil && len(p.rowFilter) > 0 } -// maxNumericOperandChars is the O(1) length pre-gate on numeric comparison -// operands, checked before ANY scan of the value. It is verdict-preserving: a -// JSON number literal carries at most four non-digit bytes (a sign, a decimal -// point, an exponent marker and its sign), so anything longer already fails -// the canonical digit bound (maxCanonicalDigits) — but proving that inside the -// canonical gate costs a full json.Valid pass plus a digit count over a -// client-controlled value, per subscriber per event on the fan-out goroutine. -// The gate refuses first, without reading the bytes. -const maxNumericOperandChars = maxCanonicalDigits + 4 - // maxTimeOperandChars is the same O(1) pre-gate for timestamp operands: the // ingest grammar's longest accepted spelling (RFC 3339 with nanoseconds and a // numeric offset) is 35 bytes, so 64 is generous slack — and the parser scans @@ -34,205 +22,6 @@ const maxNumericOperandChars = maxCanonicalDigits + 4 // per-subscriber-per-event cost. const maxTimeOperandChars = 64 -// NumericFamily classifies how a ClickHouse numeric column stores a value — -// the narrowing the row-filter comparison must apply to BOTH operands so its -// verdict matches the query path, where ClickHouse narrows the stored value at -// insert AND the filter constant at compare. The zero value is NumericNone: no -// storage model, every comparison refused — the same fail-closed zero-value -// contract as ColumnOpaque, so a future numeric type nobody classified can -// never be compared under the wrong model. -type NumericFamily uint8 - -const ( - NumericNone NumericFamily = iota // unclassified: refuse, fail closed - NumericInteger // Int*/UInt*: exact at any width - NumericFloat // Float32/Float64: IEEE rounding at Bits - NumericDecimal // Decimal*: truncation at Scale -) - -// NumericSpec is a numeric column's storage model. Bits is the bit width -// (float width for NumericFloat, integer width for NumericInteger); Unsigned -// marks UInt* (NumericInteger only); Precision and Scale are the stored total -// and fractional digit counts (NumericDecimal only, 1 ≤ Precision ≤ 76). -type NumericSpec struct { - Family NumericFamily - Bits int - Unsigned bool - Precision int - Scale int -} - -// intBounds holds each ClickHouse integer width's inclusive decimal bounds as -// canonical-form strings, computed once. A constant outside the width is not -// reliably modelable — on one and the same release, ClickHouse was measured to -// ERROR the comparison (a negative literal against an unsigned column: the -// role reads no rows), to PROMOTE and compare mathematically ('256' against a -// UInt8), and to WRAP at a width boundary ('9223372036854775808' against an -// Int64 compares as −2^63, where exact-precision comparison would ADMIT the -// −2^63 rows SQL hides under !=). Refusing out-of-range operands is the one -// rule safe under all three behaviors; the cost is availability on bounds no -// in-range data could ever satisfy differently. -var intBounds = func() map[int]struct{ sMin, sMax, uMax string } { - m := make(map[int]struct{ sMin, sMax, uMax string }, 6) - one := big.NewInt(1) - for _, bits := range []int{8, 16, 32, 64, 128, 256} { - uMax := new(big.Int).Sub(new(big.Int).Lsh(one, uint(bits)), one) - sMax := new(big.Int).Sub(new(big.Int).Lsh(one, uint(bits-1)), one) - sMin := new(big.Int).Neg(new(big.Int).Lsh(one, uint(bits-1))) - m[bits] = struct{ sMin, sMax, uMax string }{sMin.String(), sMax.String(), uMax.String()} - } - return m -}() - -// integerInRange reports whether a canonical integer form lies within the -// column's width. An unknown width refuses — fail closed, never a guessed -// range. -func (n NumericSpec) integerInRange(c string) bool { - b, ok := intBounds[n.Bits] - if !ok { - return false - } - if n.Unsigned { - return compareCanonicalDecimals(c, "0") >= 0 && compareCanonicalDecimals(c, b.uMax) <= 0 - } - return compareCanonicalDecimals(c, b.sMin) >= 0 && compareCanonicalDecimals(c, b.sMax) <= 0 -} - -// decimalInPrecision reports whether a canonical form's integer digits fit the -// column's Precision−Scale budget. A payload past it is never storable -// (DECIMAL_OVERFLOW rejects the insert); a constant past it was measured to -// promote and compare mathematically on the query path — but the integer -// widths' wrap behavior (intBounds) shows the same class is not reliably -// modelable across pairs, so the refusal keeps one rule for every family at an -// availability-only cost. A lone "0" integer part spends no digits -// (Decimal(2,2) legally stores 0.99). -func (n NumericSpec) decimalInPrecision(c string) bool { - if n.Precision < 1 || n.Scale < 0 || n.Scale > n.Precision { - // No coherent model: refuse, fail closed. The Scale bounds also protect - // truncateScale's slicing — a hand-built spec must degrade to refusal, - // never a panic on the fan-out goroutine. - return false - } - intPart, _, _ := strings.Cut(strings.TrimPrefix(c, "-"), ".") - digits := len(intPart) - if intPart == "0" { - digits = 0 - } - return digits <= n.Precision-n.Scale -} - -// compare orders two canonical decimal operands (numericCanonical / -// CanonicalNumericLiteral output) in the column's storage domain, ok=false when -// the model refuses the pair. Narrowing BOTH sides is what ClickHouse itself -// does — it narrows the payload at insert and the bound constant at compare -// (verified: Float32 stores 16777217 as 16777216 and `= '16777217'` still -// matches; Decimal(10,2) stores 1.005 as 1.00 and `= '1.005'` still matches) — -// so a threshold filter can no longer admit an event whose stored row lands on -// the other side of the comparison (the ordering fail-open raised on #381). -func (n NumericSpec) compare(a, b string) (int, bool) { - switch n.Family { - case NumericInteger: - // A fractional CONSTANT against an integer column is a per-query type - // error on the SQL path (the role reads no rows, loudly); a fractional - // PAYLOAD was never storable in the column. Refuse both — fail closed. - if strings.Contains(a, ".") || strings.Contains(b, ".") { - return 0, false - } - // Same rule for the column's range: ClickHouse's reading of an - // out-of-range constant varies by pair (error, mathematical promotion, - // or a width-boundary wrap that compares against a DIFFERENT value - // than written — see intBounds), and an out-of-range payload was never - // storable. Refuse both sides rather than model any one behavior. - if !n.integerInRange(a) || !n.integerInRange(b) { - return 0, false - } - return compareCanonicalDecimals(a, b), true - case NumericFloat: - fa, ok := narrowFloat(a, n.Bits) - if !ok { - return 0, false - } - fb, ok := narrowFloat(b, n.Bits) - if !ok { - return 0, false - } - // Both operands are now exact values of the column's float domain, so - // direct comparison IS the domain comparison — no ties left to break. - switch { - case fa < fb: - return -1, true - case fa > fb: - return 1, true - default: - return 0, true - } - case NumericDecimal: - // Precision is the range gate of the decimal family: a payload with - // integer digits past Precision−Scale is never storable (the insert is - // rejected), while a constant past it was measured to PROMOTE on the - // query path and compare mathematically — the refusal there is an - // accepted availability cost, taken because the integer widths' wrap - // behavior proves this class has no reliable single model (see - // decimalInPrecision — one story across the three sites). Scale - // truncation below cannot change integer digits, so gating - // pre-truncation is exact. - if !n.decimalInPrecision(a) || !n.decimalInPrecision(b) { - return 0, false - } - return compareCanonicalDecimals(truncateScale(a, n.Scale), truncateScale(b, n.Scale)), true - case NumericNone: - return 0, false // no storage model: refuse, fail closed - default: - return 0, false // future family nobody taught this switch: same refusal - } -} - -// narrowFloat converts a canonical decimal form to the column's float domain -// with a single correct rounding (ParseFloat at the exact bit width — never a -// float64 detour, whose double rounding can land Float32 values one ULP off). -// A magnitude the domain can't hold refuses the comparison: ClickHouse would -// store ±Inf there, and matching infinities is a verdict this evaluator can't -// prove cheaply, so the row is withheld — availability, never exposure. An -// unknown width refuses too: ParseFloat silently treats any other bitSize as -// 64, which would compare a Float32 column in the wrong (wider) domain — the -// fail-open direction — instead of the zero-value-refuses contract the -// integer and decimal families keep. -func narrowFloat(canonical string, bits int) (float64, bool) { - if bits != 32 && bits != 64 { - return 0, false - } - f, err := strconv.ParseFloat(canonical, bits) - if err != nil { - return 0, false - } - return f, true -} - -// truncateScale narrows a canonical decimal form to scale fractional digits, -// truncating toward zero — ClickHouse's Decimal cast (1.005, 1.006 and 1.009 -// all store as 1.00 in a Decimal(10,2); rounding would predict 1.01). The -// result is re-canonicalized (trailing zeros trimmed, bare "-0" folded) so it -// stays valid compareCanonicalDecimals input. -func truncateScale(canonical string, scale int) string { - intPart, frac, hasFrac := strings.Cut(canonical, ".") - if !hasFrac { - return canonical - } - if len(frac) > scale { - frac = frac[:scale] - } - for len(frac) > 0 && frac[len(frac)-1] == '0' { - frac = frac[:len(frac)-1] - } - if len(frac) > 0 { - return intPart + "." + frac - } - if intPart == "-0" { - return "0" - } - return intPart -} - // ColumnKind classifies a column's ClickHouse type for the in-memory row-filter // comparison. The zero value is ColumnOpaque, so a nil map, a column absent from // the map, and a column the schema doesn't know all land on the most conservative @@ -487,65 +276,3 @@ func compareScalar(rowVal any, filterVal string, spec ColumnSpec) (int, bool) { return 0, false // unknown future kind: refuse to compare, fail closed } } - -// numericCanonical renders a payload value as the canonical decimal form the -// numeric comparison consumes, ok=false for anything that is not a number a -// ClickHouse numeric column could have stored: booleans, structured values and -// null, spellings outside the JSON number grammar ("Inf", "NaN", "0x1f", -// "007"), values whose exact digits were lost upstream (float64 at/past 2^53), -// and anything past the canonical digit bound. String and json.Number inputs -// take the claim side's own gates (CanonicalNumericLiteral / CanonicalScalar), -// so the payload and constant sides can never disagree on what counts as a -// number or how it is spelled. -func numericCanonical(v any) (string, bool) { - switch x := v.(type) { - case string: - if len(x) > maxNumericOperandChars { - return "", false - } - return CanonicalNumericLiteral(x) - case json.Number: - if len(x) > maxNumericOperandChars { - return "", false - } - return CanonicalScalar(x) - case float64: - // CanonicalScalar applies the 2^53 exactness guard and renders - // positionally; the literal gate then re-canonicalizes the one - // rendering FormatFloat emits that canonical form forbids ("-0"). - s, ok := CanonicalScalar(x) - if !ok { - return "", false - } - return CanonicalNumericLiteral(s) - default: - return "", false - } -} - -// scalarString renders a JSON-decoded scalar as the exact BYTES compared under -// ColumnText and ColumnOpaque — deliberately the payload's raw spelling, never -// a canonical form: a String column stores the payload text verbatim, so byte -// comparison against it must use that spelling (canonicalizing "1.0" to "1" -// here would move equality away from what ClickHouse stores). Numeric coercion -// deliberately does NOT live here — the ColumnNumeric arm routes both operands -// through the claim side's canonical machinery (numericCanonical). Non-scalars -// (arrays, objects, null) return ok=false so the predicate fails closed rather -// than guessing. The float64 case serves callers that decoded without -// UseNumber (the stream itself always does); -1 precision emits the shortest -// round-trip form without an exponent, so integer IDs read back as "123", not -// "1.23e+02". -func scalarString(v any) (string, bool) { - switch x := v.(type) { - case string: - return x, true - case json.Number: - return string(x), true - case float64: - return strconv.FormatFloat(x, 'f', -1, 64), true - case bool: - return strconv.FormatBool(x), true - default: - return "", false - } -} diff --git a/internal/stream/bucket.go b/internal/stream/bucket.go index 5e0ed35f..1ffaf2b9 100644 --- a/internal/stream/bucket.go +++ b/internal/stream/bucket.go @@ -3,9 +3,10 @@ package stream import "sync" // Bucket is a concurrency-safe set of subscribers. Push fans one Frame out to -// every member fire-and-forget (the keepalive wheel's primitive); Snapshot exposes -// the members so the event Hub can fan out while inspecting each Send result (to -// count drops and, later, evict). +// every member fire-and-forget (the keepalive wheel and the Hub's no-row-filter +// fast path; Send itself counts any queue-full drop); Snapshot exposes the +// members so the event Hub can evaluate row visibility per subscriber before +// sending (and, later, evict). type Bucket interface { Add(sub *Subscriber) Remove(sub *Subscriber) diff --git a/internal/stream/bucket_test.go b/internal/stream/bucket_test.go index ee78bbfe..25e09e6c 100644 --- a/internal/stream/bucket_test.go +++ b/internal/stream/bucket_test.go @@ -12,8 +12,8 @@ func TestSubscriberSet_AddRemoveLen(t *testing.T) { s := newSubscriberSet() assert.Equal(t, 0, s.Len()) - s1 := NewSubscriber(nil) - s2 := NewSubscriber(nil) + s1 := NewSubscriber(nil, nil) + s2 := NewSubscriber(nil, nil) s.Add(s1) s.Add(s2) assert.Equal(t, 2, s.Len()) @@ -31,7 +31,7 @@ func TestSubscriberSet_AddRemoveLen(t *testing.T) { func TestSubscriberSet_PushIsNonBlockingAndDropsWhenFull(t *testing.T) { t.Parallel() s := newSubscriberSet() - sub := newSubscriber(1) // cap-1 so the second push has nowhere to go + sub := newSubscriber(1, nil) // cap-1 so the second push has nowhere to go s.Add(sub) payload := Frame{Kind: KindEvent, Data: []byte("payload")} @@ -50,7 +50,7 @@ func TestSubscriberSet_PushIsNonBlockingAndDropsWhenFull(t *testing.T) { func TestSubscriberSet_PushAfterRemove_NoPanicNoBlock(t *testing.T) { t.Parallel() s := newSubscriberSet() - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) s.Add(sub) s.Remove(sub) frame := Frame{Kind: KindKeepalive, Data: []byte(":\n\n")} @@ -80,8 +80,8 @@ func TestSubscriberSet_PushToStoppedReader_DropsAndDoesNotStallWheel(t *testing. t.Parallel() s := newSubscriberSet() // stuck never drains, so its cap-1 buffer fills and stays full; live keeps room. - stuck := newSubscriber(1) - live := newSubscriber(4) + stuck := newSubscriber(1, nil) + live := newSubscriber(4, nil) s.Add(stuck) s.Add(live) frame := Frame{Kind: KindKeepalive, Data: []byte(":\n\n")} diff --git a/internal/stream/heartbeat_test.go b/internal/stream/heartbeat_test.go index b8a5b5ab..3794b272 100644 --- a/internal/stream/heartbeat_test.go +++ b/internal/stream/heartbeat_test.go @@ -42,7 +42,7 @@ func TestHeartbeater_AddPlacesSubscriberInLastToFireBucket(t *testing.T) { // A long period means the ticker never fires during the test, so bucket // membership is deterministic. hb := NewHeartbeater(time.Hour, 3) - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) hb.Add(sub) // hand starts at 0; buckets[0] fires next, so a new subscriber lands in @@ -66,7 +66,7 @@ func TestHeartbeater_RotatesOneBucketPerTick(t *testing.T) { // A roomy buffer means a (buggy) double-push would be observed, not dropped. subs := make([]*Subscriber, buckets) for i := range subs { - subs[i] = newSubscriber(4) + subs[i] = newSubscriber(4, nil) hb.buckets[i].Add(subs[i]) } @@ -101,7 +101,7 @@ func TestHeartbeater_PushesKeepaliveToIdleSubscriber(t *testing.T) { hb := NewHeartbeater(5*time.Millisecond, 1) go hb.Run(t.Context()) - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) hb.Add(sub) defer hb.Remove(sub) @@ -140,13 +140,13 @@ func TestHeartbeater_RemoveIdempotentAndWithoutAdd(t *testing.T) { // Never added: sub.bucket is nil, so Remove is a no-op. The stream handler's // deferred Remove must be safe even when Add was skipped (e.g. the wheel is // wired but the request bailed before registering). - never := NewSubscriber(nil) + never := NewSubscriber(nil, nil) assert.NotPanics(t, func() { hb.Remove(never) }) assert.Equal(t, 0, hb.Len()) // Added once, removed twice: the second Remove is a no-op, not a panic or a // negative count. - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) hb.Add(sub) hb.Remove(sub) assert.NotPanics(t, func() { hb.Remove(sub) }) @@ -173,7 +173,7 @@ func TestHeartbeater_ConcurrentAddRemovePush_Race(t *testing.T) { go func() { defer wg.Done() for range iterations { - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) hb.Add(sub) select { // drain whatever the wheel delivered, then leave case <-sub.Frames(): diff --git a/internal/stream/hub.go b/internal/stream/hub.go index 64c7c832..ec99002a 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -151,11 +151,8 @@ func (h *Hub) Broadcast(topic string, raw []byte) { if !perms.HasRowFilter() { // No row-level security for this role: one projection serves the whole // bucket, regardless of per-subscriber claims (the #294/#353 fast path). - for _, sub := range rb.bucket.Snapshot() { - if !sub.Send(frame) { - h.metric.FrameDropped(KindEvent) - } - } + // Push is fire-and-forget; Send itself counts any queue-full drop. + rb.bucket.Push(frame) continue } @@ -168,18 +165,26 @@ func (h *Hub) Broadcast(topic string, raw []byte) { specsResolved = true } for _, sub := range rb.bucket.Snapshot() { - subPerms := policy.Evaluate(p, rb.role, evt.TableName, "select", sub.claims) - if !subPerms.RowVisible(evt.Data, colSpecs) { - h.metric.RowWithheld(evt.TableName, rb.role) - continue // this row is filtered out for this subscriber - } - if !sub.Send(frame) { - h.metric.FrameDropped(KindEvent) + if h.rowAdmitted(p, rb.role, &evt, sub.claims, colSpecs) { + sub.Send(frame) } } } } +// rowAdmitted reports whether claims admit this event's row under the role's +// row-filter, counting a withheld row when they don't. It is the one admission +// step shared by the live fan-out (per subscriber) and replay (per connection), +// so the two delivery paths can't drift on how row-level security is evaluated. +func (h *Hub) rowAdmitted(p *policy.Policy, role string, evt *ingest.EventMessage, claims map[string]any, colSpecs map[string]policy.ColumnSpec) bool { + perms := policy.Evaluate(p, role, evt.TableName, "select", claims) + if !perms.RowVisible(evt.Data, colSpecs) { + h.metric.RowWithheld(evt.TableName, role) + return false + } + return true +} + // columnSpecs classifies each of the table's columns for the row-filter evaluator: // DateTime/DateTime64 columns compare as instants (through discovery's // Column.TimeParser — the same grammar ingest canonicalization applies, so a @@ -213,14 +218,7 @@ func (h *Hub) columnSpecs(table string) map[string]policy.ColumnSpec { // never a comparison under guessed semantics. spec := policy.ColumnSpec{Kind: policy.ColumnNumeric} if st, ok := discovery.NumericStorageOf(c.Type); ok { - switch { - case st.Integer: - spec.Numeric = policy.NumericSpec{Family: policy.NumericInteger, Bits: st.IntBits, Unsigned: st.Unsigned} - case st.FloatBits != 0: - spec.Numeric = policy.NumericSpec{Family: policy.NumericFloat, Bits: st.FloatBits} - default: - spec.Numeric = policy.NumericSpec{Family: policy.NumericDecimal, Precision: st.Precision, Scale: st.Scale} - } + spec.Numeric = NumericSpecOf(st) } m[c.Name] = spec case discovery.IsStringType(c.Type): @@ -230,6 +228,21 @@ func (h *Hub) columnSpecs(table string) map[string]policy.ColumnSpec { return m } +// NumericSpecOf renders discovery's storage classification as the policy +// evaluator's storage model. Exported so the tests/integration differential +// oracle builds specs through the very mapping production uses — one source, +// so the oracle can't keep validating a mapping the Hub no longer applies. +func NumericSpecOf(st discovery.NumericStorage) policy.NumericSpec { + switch { + case st.Integer: + return policy.NumericSpec{Family: policy.NumericInteger, Bits: st.IntBits, Unsigned: st.Unsigned} + case st.FloatBits != 0: + return policy.NumericSpec{Family: policy.NumericFloat, Bits: st.FloatBits} + default: + return policy.NumericSpec{Family: policy.NumericDecimal, Precision: st.Precision, Scale: st.Scale} + } +} + // decodeEvent parses raw as a published EventMessage, reporting whether it is one // (a decode error, trailing garbage, or a missing table name all read as "not an // EventMessage", which projectColumns then fails closed under a policy). Numbers @@ -271,18 +284,20 @@ func (h *Hub) snapshotPolicy() (p *policy.Policy, filter bool) { // replay shares the Hub's policy store and schema registry with the live fan-out — // the handler can't accidentally project replay against a different (or nil) // policy. Replay is already per-connection, so row-level security evaluates against -// this connection's claims directly; the returned closure caches the per-table -// column-kind lookup across the replay loop (the same hoist Broadcast does per -// event), so a large Last-Event-ID gap-fill doesn't pay one registry lookup and map -// build per event. The closure is for a single goroutine — each connection makes -// its own. The live path uses Broadcast. +// this connection's claims directly; the returned closure holds one policy snapshot +// for the whole gap-fill (matching Broadcast's one-snapshot-per-event — a reload +// landing mid-replay applies from the first live event) and caches the per-table +// column-kind lookup across the replay loop, so a large Last-Event-ID gap-fill +// doesn't pay a store read-lock plus a registry lookup and map build per event. +// The closure is for a single goroutine — each connection makes its own. The live +// path uses Broadcast. func (h *Hub) ReplayProjector(role string, claims map[string]any) func(raw []byte) (Frame, bool) { + p, filter := h.snapshotPolicy() var colSpecs map[string]policy.ColumnSpec specsFor := "" // table name colSpecs was resolved for ("" ⇒ not yet resolved) return func(raw []byte) (Frame, bool) { var evt ingest.EventMessage decoded := decodeEvent(raw, &evt) - p, filter := h.snapshotPolicy() wire, perms, ok := projectColumns(p, filter, role, &evt, raw, decoded) if !ok { return Frame{}, false @@ -294,9 +309,7 @@ func (h *Hub) ReplayProjector(role string, claims map[string]any) func(raw []byt colSpecs = h.columnSpecs(evt.TableName) specsFor = evt.TableName } - subPerms := policy.Evaluate(p, role, evt.TableName, "select", claims) - if !subPerms.RowVisible(evt.Data, colSpecs) { - h.metric.RowWithheld(evt.TableName, role) + if !h.rowAdmitted(p, role, &evt, claims, colSpecs) { return Frame{}, false // this row is filtered out for these claims } } diff --git a/internal/stream/hub_test.go b/internal/stream/hub_test.go index b1aa4e69..db9418ac 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" ) @@ -97,7 +98,7 @@ func TestHub_ProjectsOncePerRole_FanOutToAllSubscribers(t *testing.T) { hub := NewHub(nil, nil, nil) // nil store ⇒ passthrough, no filtering const topic = "ingest.clicks" - a, b := NewSubscriber(nil), NewSubscriber(nil) + a, b := NewSubscriber(nil, nil), NewSubscriber(nil, nil) hub.Add(topic, "public", a) hub.Add(topic, "public", b) @@ -129,8 +130,8 @@ func TestHub_ProjectsPerRole_ColumnFilterAndDenial(t *testing.T) { hub := NewHub(policy.NewMemoryStore(p), nil, nil) const topic = "ingest.clicks" - viewer := NewSubscriber(nil) - blocked := NewSubscriber(nil) + viewer := NewSubscriber(nil, nil) + blocked := NewSubscriber(nil, nil) hub.Add(topic, "viewer", viewer) hub.Add(topic, "blocked", blocked) @@ -193,7 +194,7 @@ func TestHub_ProjectsPerRole_DistinctRolesGetDistinctFrames(t *testing.T) { hub := NewHub(policy.NewMemoryStore(p), nil, nil) const topic = "ingest.clicks" - viewer, editor := NewSubscriber(nil), NewSubscriber(nil) + viewer, editor := NewSubscriber(nil, nil), NewSubscriber(nil, nil) hub.Add(topic, "viewer", viewer) hub.Add(topic, "editor", editor) @@ -247,9 +248,9 @@ func TestHub_RowFilter_PerSubscriberIsolation(t *testing.T) { hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) const topic = "ingest.clicks" - acme := NewSubscriber(jwtClaims(t, map[string]any{"tenant": "acme"})) - globex := NewSubscriber(jwtClaims(t, map[string]any{"tenant": "globex"})) - noTenant := NewSubscriber(jwtClaims(t, map[string]any{"role": "viewer"})) // valid token, no tenant claim + acme := NewSubscriber(jwtClaims(t, map[string]any{"tenant": "acme"}), nil) + globex := NewSubscriber(jwtClaims(t, map[string]any{"tenant": "globex"}), nil) + noTenant := NewSubscriber(jwtClaims(t, map[string]any{"role": "viewer"}), nil) // valid token, no tenant claim hub.Add(topic, "viewer", acme) hub.Add(topic, "viewer", globex) hub.Add(topic, "viewer", noTenant) @@ -276,8 +277,9 @@ func TestHub_RowFilter_PerSubscriberIsolation(t *testing.T) { // TestHub_RowFilter_ClaimsSnapshotImmuneToCallerMutation: NewSubscriber deep-copies // the claims, so a caller that keeps the source map (the middleware-owned // jwt.MapClaims outlives Hub.Add) can neither widen row visibility after -// registration nor race Broadcast's claims read. The mutation targets a NESTED -// value to prove the copy is deep, not a top-level shallow copy. +// registration nor race Broadcast's claims read. The mutations target a NESTED +// map value and an ARRAY element to prove the copy is deep on both structured +// arms (cloneClaimValue), not a top-level shallow copy. func TestHub_RowFilter_ClaimsSnapshotImmuneToCallerMutation(t *testing.T) { t.Parallel() p := &policy.Policy{ @@ -292,7 +294,7 @@ func TestHub_RowFilter_ClaimsSnapshotImmuneToCallerMutation(t *testing.T) { org := map[string]any{"tenant": "globex"} claims := map[string]any{"org": org} - sub := NewSubscriber(claims) + sub := NewSubscriber(claims, nil) hub.Add(topic, "viewer", sub) org["tenant"] = "acme" // the caller mutates its retained map after registration @@ -303,6 +305,30 @@ func TestHub_RowFilter_ClaimsSnapshotImmuneToCallerMutation(t *testing.T) { hub.Broadcast(topic, rawEvent(t, "clicks", "t", map[string]any{"tenant_id": "globex", "page": "/g"})) assert.Equal(t, "/g", frameData(t, recvFrame(t, sub))["data"].(map[string]any)["page"], "the snapshot keeps admitting the tenant the connection authenticated as") + + // The []any arm is just as authorization-relevant: an _in-shaped filter reads + // array elements, so mutating a retained slice element must not move the + // subscriber's row entitlement either. + inPolicy := &policy.Policy{ + Tables: map[string]policy.TablePolicy{ + "clicks": {Select: map[string]policy.RolePermissions{ + "viewer": {Filter: map[string]policy.Filter{"tenant_id": {In: new("{{ jwt.tenants }}")}}}, + }}, + }, + } + inHub := NewHub(policy.NewMemoryStore(inPolicy), nil, nil) + tenants := []any{"globex"} + inSub := NewSubscriber(map[string]any{"tenants": tenants}, nil) + inHub.Add(topic, "viewer", inSub) + + tenants[0] = "acme" // the caller mutates its retained slice after registration + + inHub.Broadcast(topic, rawEvent(t, "clicks", "t", map[string]any{"tenant_id": "acme", "page": "/a2"})) + assertNoFrame(t, inSub) // membership follows the snapshot ("globex"), not the mutation + + inHub.Broadcast(topic, rawEvent(t, "clicks", "t", map[string]any{"tenant_id": "globex", "page": "/g2"})) + assert.Equal(t, "/g2", frameData(t, recvFrame(t, inSub))["data"].(map[string]any)["page"], + "the array snapshot keeps admitting the tenant list the connection authenticated with") } // TestHub_RowFilter_MissingColumn_FailsClosed: an event that lacks the filtered @@ -312,7 +338,7 @@ func TestHub_RowFilter_MissingColumn_FailsClosed(t *testing.T) { hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) const topic = "ingest.clicks" - acme := NewSubscriber(map[string]any{"tenant": "acme"}) + acme := NewSubscriber(map[string]any{"tenant": "acme"}, nil) hub.Add(topic, "viewer", acme) hub.Broadcast(topic, rawEvent(t, "clicks", "2026-06-26T00:00:00Z", @@ -329,8 +355,8 @@ func TestHub_RowFilter_SharedProjectionAcrossSameClaims(t *testing.T) { hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) const topic = "ingest.clicks" - a := NewSubscriber(map[string]any{"tenant": "acme"}) - b := NewSubscriber(map[string]any{"tenant": "acme"}) + a := NewSubscriber(map[string]any{"tenant": "acme"}, nil) + b := NewSubscriber(map[string]any{"tenant": "acme"}, nil) hub.Add(topic, "viewer", a) hub.Add(topic, "viewer", b) @@ -368,7 +394,7 @@ func TestHub_RowFilter_NumericOrdering_SchemaInformed(t *testing.T) { hub := NewHub(policy.NewMemoryStore(p), reg, nil) const topic = "ingest.clicks" - sub := NewSubscriber(nil) // constant filter value ⇒ no claims needed + sub := NewSubscriber(nil, nil) // constant filter value ⇒ no claims needed hub.Add(topic, "viewer", sub) hub.Broadcast(topic, rawEvent(t, "clicks", "t1", map[string]any{"amount": float64(9), "page": "/a"})) @@ -381,7 +407,7 @@ func TestHub_RowFilter_NumericOrdering_SchemaInformed(t *testing.T) { // way, so both rows are withheld — including the one the schema-informed path // delivers above. noSchema := NewHub(policy.NewMemoryStore(p), nil, nil) - blind := NewSubscriber(nil) + blind := NewSubscriber(nil, nil) noSchema.Add(topic, "viewer", blind) noSchema.Broadcast(topic, rawEvent(t, "clicks", "t1", map[string]any{"amount": float64(9), "page": "/a"})) noSchema.Broadcast(topic, rawEvent(t, "clicks", "t2", map[string]any{"amount": float64(250), "page": "/b"})) @@ -408,7 +434,7 @@ func TestHub_RowFilter_FloatNarrowing_SchemaInformed(t *testing.T) { } hub := NewHub(policy.NewMemoryStore(p), reg, nil) const topic = "ingest.clicks" - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) hub.Add(topic, "viewer", sub) hub.Broadcast(topic, rawEvent(t, "clicks", "t1", map[string]any{"score": json.Number("16777217")})) @@ -421,7 +447,7 @@ func TestHub_RowFilter_FloatNarrowing_SchemaInformed(t *testing.T) { func TestHub_TopicIsolation(t *testing.T) { t.Parallel() hub := NewHub(nil, nil, nil) - clicks, views := NewSubscriber(nil), NewSubscriber(nil) + clicks, views := NewSubscriber(nil, nil), NewSubscriber(nil, nil) hub.Add("ingest.clicks", "public", clicks) hub.Add("ingest.views", "public", views) @@ -471,7 +497,7 @@ func TestHub_PassthroughAndFailClosed(t *testing.T) { t.Parallel() hub := NewHub(tt.store, nil, nil) const topic = "ingest.custom" - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) hub.Add(topic, "public", sub) hub.Broadcast(topic, []byte(tt.payload)) @@ -492,7 +518,7 @@ func TestHub_AddRemoveGCsBucketsAndTopics(t *testing.T) { t.Parallel() hub := NewHub(nil, nil, nil) const topic = "ingest.clicks" - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) hub.Add(topic, "public", sub) assert.Equal(t, 1, hub.Len(topic)) @@ -528,18 +554,26 @@ func TestHub_SlowConsumerDropIncrementsMetric(t *testing.T) { otel.SetMeterProvider(savedMP) }) - hub := NewHub(nil, nil, NewMetrics()) + m := NewMetrics() + hub := NewHub(nil, nil, m) const topic = "ingest.clicks" - sub := newSubscriber(1) // cap-1: the second undrained broadcast drops + sub := newSubscriber(1, m) // cap-1: the second undrained broadcast drops; the shared seam NewSubscriber wires metrics through hub.Add(topic, "public", sub) raw := rawEvent(t, "clicks", "t", map[string]any{"a": float64(1)}) hub.Broadcast(topic, raw) // fills the queue - hub.Broadcast(topic, raw) // dropped + hub.Broadcast(topic, raw) // dropped by Send, kind=event + + // The keepalive path drops through the same Send: a Push into the full + // queue counts under the frame's own kind, with no counting at the call site. + b := newSubscriberSet() + b.Add(sub) + b.Push(Frame{Kind: KindKeepalive, Data: []byte(": keepalive\n\n")}) var rm metricdata.ResourceMetrics require.NoError(t, reader.Collect(context.Background(), &rm)) - assert.Equal(t, int64(1), sumByName(rm, "wavehouse_sse_dropped_frames_total")) + assert.Equal(t, int64(1), sumByNameKind(rm, "wavehouse_sse_dropped_frames_total", KindEvent)) + assert.Equal(t, int64(1), sumByNameKind(rm, "wavehouse_sse_dropped_frames_total", KindKeepalive)) } func TestHub_ReplayProjector(t *testing.T) { @@ -630,7 +664,7 @@ func TestHub_ConcurrentAddRemoveBroadcast_Race(t *testing.T) { go func(role string) { defer wg.Done() for range 50 { - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) hub.Add(topic, role, sub) hub.Broadcast(topic, raw) hub.Remove(topic, role, sub) @@ -670,7 +704,7 @@ func TestHub_ConcurrentRowFilteredBroadcast_Race(t *testing.T) { go func(tenant string) { defer wg.Done() for range 50 { - sub := NewSubscriber(map[string]any{"tenant": tenant}) + sub := NewSubscriber(map[string]any{"tenant": tenant}, nil) hub.Add(topic, "viewer", sub) hub.Remove(topic, "viewer", sub) } @@ -709,8 +743,8 @@ func TestHub_RowFilter_BigIntegerExact(t *testing.T) { // bare JSON-number tenant claim reaches the filter exactly as production // decodes it (json.Number since WithJSONNumber; before that fix, float64 — // which collapsed these neighbors and delivered the cross-tenant row). - neighbor := NewSubscriber(jwtClaims(t, map[string]any{"tenant": json.Number("10000000000000000")})) - exact := NewSubscriber(jwtClaims(t, map[string]any{"tenant": json.Number("10000000000000001")})) + neighbor := NewSubscriber(jwtClaims(t, map[string]any{"tenant": json.Number("10000000000000000")}), nil) + exact := NewSubscriber(jwtClaims(t, map[string]any{"tenant": json.Number("10000000000000001")}), nil) hub.Add(topic, "viewer", neighbor) hub.Add(topic, "viewer", exact) @@ -749,7 +783,7 @@ func TestHub_RowFilter_TimestampInstantMatch(t *testing.T) { hub := NewHub(policy.NewMemoryStore(p), reg, nil) const topic = "ingest.clicks" - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) hub.Add(topic, "viewer", sub) // The canonical wire spelling ingest publishes: same instant, different bytes. @@ -781,8 +815,8 @@ func TestHub_RowFilterWithheldIncrementsMetric(t *testing.T) { hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, NewMetrics()) const topic = "ingest.clicks" - acme := NewSubscriber(map[string]any{"tenant": "acme"}) - globex := NewSubscriber(map[string]any{"tenant": "globex"}) + acme := NewSubscriber(map[string]any{"tenant": "acme"}, nil) + globex := NewSubscriber(map[string]any{"tenant": "globex"}, nil) hub.Add(topic, "viewer", acme) hub.Add(topic, "viewer", globex) @@ -820,7 +854,7 @@ func BenchmarkBroadcast_RowFilteredFanout(b *testing.B) { if i%2 == 1 { tenant = "globex" } - subs[i] = NewSubscriber(map[string]any{"tenant": tenant}) + subs[i] = NewSubscriber(map[string]any{"tenant": tenant}, nil) hub.Add(topic, "viewer", subs[i]) } b.ReportAllocs() @@ -892,6 +926,31 @@ func TestWireFrame(t *testing.T) { } } +// sumByNameKind totals the named Int64 sum instrument's datapoints carrying +// the given kind attribute — pins that a drop is labeled with the dropped +// frame's own kind, which sumByName's across-kinds total can't see. +func sumByNameKind(rm metricdata.ResourceMetrics, name, kind string) int64 { + for _, sm := range rm.ScopeMetrics { + for _, md := range sm.Metrics { + if md.Name != name { + continue + } + sum, ok := md.Data.(metricdata.Sum[int64]) + if !ok { + return 0 + } + var total int64 + for _, dp := range sum.DataPoints { + if v, ok := dp.Attributes.Value(attribute.Key("kind")); ok && v.AsString() == kind { + total += dp.Value + } + } + return total + } + } + return 0 +} + // sumByName totals all datapoints of an Int64 sum instrument across kinds. func sumByName(rm metricdata.ResourceMetrics, name string) int64 { for _, sm := range rm.ScopeMetrics { diff --git a/internal/stream/subscriber.go b/internal/stream/subscriber.go index 8c67f693..1617673a 100644 --- a/internal/stream/subscriber.go +++ b/internal/stream/subscriber.go @@ -42,13 +42,19 @@ type Subscriber struct { // here and consumed by the handler; the policy that *closes* it (consecutive-drop // threshold) lands with the slow-consumer follow-up (#294 / #94). evict chan struct{} + + // metric counts queue-full drops inside Send itself (by frame kind), so every + // producer — the event fan-out, replay, the keepalive wheel — is covered without + // each call site remembering to count. Nil-safe (nil in tests). + metric *Metrics } // NewSubscriber returns a Subscriber ready to register with a Heartbeater and the // event Hub, carrying the connection's JWT claims (nil for a tokenless caller), -// deep-copied — see the claims field for the snapshot rationale. -func NewSubscriber(claims map[string]any) *Subscriber { - s := newSubscriber(defaultSubscriberQueue) +// deep-copied — see the claims field for the snapshot rationale — and the shared +// stream metrics (nil-safe) that Send counts drops on. +func NewSubscriber(claims map[string]any, m *Metrics) *Subscriber { + s := newSubscriber(defaultSubscriberQueue, m) s.claims = cloneClaimsMap(claims) return s } @@ -83,9 +89,11 @@ func cloneClaimValue(v any) any { } // newSubscriber builds a Subscriber with an explicit queue size — the seam tests -// use to exercise the full-queue/drop path without enqueuing 64 frames. -func newSubscriber(size int) *Subscriber { - return &Subscriber{out: make(chan Frame, size), evict: make(chan struct{})} +// use to exercise the full-queue/drop path without enqueuing 64 frames. It is +// the one place the drop-counting metric is wired, so the production +// constructor and the drop tests share it. +func newSubscriber(size int, m *Metrics) *Subscriber { + return &Subscriber{out: make(chan Frame, size), evict: make(chan struct{}), metric: m} } // Frames is the queue of ready-to-write frames; the handler writes whatever @@ -95,14 +103,15 @@ func (s *Subscriber) Frames() <-chan Frame { } // Send enqueues one frame without blocking, returning false if the queue is full -// (a slow consumer). Keepalive callers ignore the result — a full queue already -// has a frame pending that keeps the stream alive. The event fan-out uses the -// result to count drops. +// (a slow consumer). The drop is counted here, labeled by the frame's kind, so no +// producer can forget to; callers may ignore the result (a keepalive that drops +// coalesces harmlessly — the full queue keeps the stream alive anyway). func (s *Subscriber) Send(f Frame) bool { select { case s.out <- f: return true default: + s.metric.FrameDropped(f.Kind) return false } } diff --git a/internal/stream/subscriber_test.go b/internal/stream/subscriber_test.go index 740f1e59..e1b1a99a 100644 --- a/internal/stream/subscriber_test.go +++ b/internal/stream/subscriber_test.go @@ -8,7 +8,7 @@ import ( func TestSubscriber_SendDeliversThenDropsWhenFull(t *testing.T) { t.Parallel() - sub := newSubscriber(1) // cap-1 so the second Send has nowhere to go + sub := newSubscriber(1, nil) // cap-1 so the second Send has nowhere to go frame := Frame{Kind: KindKeepalive, Data: []byte(":\n\n")} // The cap-1 queue takes the first frame; the second has nowhere to go, so it's @@ -29,7 +29,7 @@ func TestSubscriber_SendDeliversThenDropsWhenFull(t *testing.T) { func TestSubscriber_EvictedIsOpenUntilClosed(t *testing.T) { t.Parallel() - sub := NewSubscriber(nil) + sub := NewSubscriber(nil, nil) // The eviction seam is inert until the slow-consumer follow-up closes it: the // channel stays open, so a non-blocking read finds nothing. diff --git a/tests/integration/rowfilter_narrowing_test.go b/tests/integration/rowfilter_narrowing_test.go index 1157a169..dffcaf8b 100644 --- a/tests/integration/rowfilter_narrowing_test.go +++ b/tests/integration/rowfilter_narrowing_test.go @@ -13,6 +13,7 @@ import ( "github.com/Wave-RF/WaveHouse/internal/discovery" "github.com/Wave-RF/WaveHouse/internal/policy" + "github.com/Wave-RF/WaveHouse/internal/stream" ) // TestRowFilterNumeric_DifferentialAgainstClickHouse pins the stream/query @@ -170,21 +171,14 @@ func TestRowFilterNumeric_DifferentialAgainstClickHouse(t *testing.T) { } // numericColumnSpec builds the policy.ColumnSpec for a numeric ClickHouse type -// exactly the way stream.Hub's columnSpecs does, from the same classifier. +// through the very mapping production uses (stream.NumericSpecOf, the same +// classifier and spec builder as stream.Hub's columnSpecs), so this oracle can +// never validate a mapping the Hub no longer applies. func numericColumnSpec(t *testing.T, chType string) policy.ColumnSpec { t.Helper() st, ok := discovery.NumericStorageOf(chType) require.True(t, ok, "corpus types must classify: %s", chType) - spec := policy.ColumnSpec{Kind: policy.ColumnNumeric} - switch { - case st.Integer: - spec.Numeric = policy.NumericSpec{Family: policy.NumericInteger, Bits: st.IntBits, Unsigned: st.Unsigned} - case st.FloatBits != 0: - spec.Numeric = policy.NumericSpec{Family: policy.NumericFloat, Bits: st.FloatBits} - default: - spec.Numeric = policy.NumericSpec{Family: policy.NumericDecimal, Precision: st.Precision, Scale: st.Scale} - } - return spec + return policy.ColumnSpec{Kind: policy.ColumnNumeric, Numeric: stream.NumericSpecOf(st)} } // streamVerdict resolves a one-operator literal filter through the full