diff --git a/AGENTS.md b/AGENTS.md index bf25f5bf..c569499a 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, structural policy predicate/limit emission, 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.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 0c1dfbb4..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 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.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. @@ -39,11 +39,14 @@ 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 -- **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 is not yet claims-scoped — [#381](https://github.com/Wave-RF/WaveHouse/issues/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. +- **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. - **`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/SECURITY.md b/SECURITY.md index be700fdc..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 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 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/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 7d9a0d17..c2838a01 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**: @@ -225,18 +225,18 @@ Multiple columns (and multiple operators on one column) are combined with `AND`. ### JWT claim templating -Any `filter` or `check` operator value may interpolate token claims with `{{ jwt. }}`: +Any `filter` or `check` operator 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. -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**. 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. Claim paths may contain only letters, digits, `_`, and `.` (the segment separator). Any `{{ … }}` fragment that is not a well-formed `{{ jwt. }}` template — a path outside that grammar (a hyphen: `{{ jwt.tenant-id }}`; a namespaced claim: `{{ jwt.https://app.example.com/tenant_id }}`), a missing or misspelled `jwt.` prefix, or an unterminated `{{` — is **not** recognized as a template, and left unchecked the resolver would bind the literal `{{ … }}` text as a value. (Policy values have no other placeholder syntax; a pipe's `{{param}}` placeholders are a different mechanism and are not accepted here.) That is *not* fail-closed: on a read filter `_neq`/`_lt` would then match essentially every row (a leak), and on a write `check` the literal text would be stamped into every inserted row (silent corruption). So such a policy is **rejected when it is written**: a bootstrap policy file carrying one makes WaveHouse refuse to start when the store is seeded from it (a populated KV store skips the file), and a `PUT` on `/v1/admin/policy` (or a `POST` to `/v1/admin/policy/validate`) returns `400`. A policy already stored in NATS KV is *not* re-validated when a node loads it ([#461](https://github.com/Wave-RF/WaveHouse/issues/461)), so after upgrading, re-`PUT` your policy once to surface a template written before this rule existed — until you do, that stored policy keeps binding the literal text, and the leak above stays live for it. Flatten hyphenated or namespaced claims into a supported path at your identity provider — on Auth0, an [Action can set a flat custom claim](https://auth0.com/docs/secure/tokens/json-web-tokens/create-custom-claims) outside the registered OIDC names (its legacy Rules required URL namespacing, which established tenants often still carry), and namespacing is a common OIDC convention elsewhere, so a namespaced claim is the shape you are most likely to meet first. -Row filters apply on the structured-query path. The SSE stream does not yet scope rows by token claims (tracked in [#381](https://github.com/Wave-RF/WaveHouse/issues/381)), and named pipes authorize by `allowed_roles` membership alone — scope a pipe's exposure in its SQL text, since neither the table policy's row `filter` nor its column allow/deny list is applied on the pipe path (see [Named Pipes](/pipes#authorizing-a-pipe)). +Row filters apply on the structured-query path and, per subscriber, on the live SSE stream — the stream evaluates the same resolved predicates in memory against each subscriber's token claims (see the [enforcement caution](#where-each-rule-is-enforced) for what its in-memory comparison can and cannot decide). Named pipes authorize by `allowed_roles` membership alone — scope a pipe's exposure in its SQL text, since neither the table policy's row `filter` nor its column allow/deny list is applied on the pipe path (see [Named Pipes](/pipes#authorizing-a-pipe)). ## Insert checks @@ -378,12 +378,30 @@ 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 | -:::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. 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 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. 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. + +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. (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 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 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. + +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 @@ -531,7 +549,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; a claim the token doesn't carry fails the filter closed (no rows), and a malformed template is rejected when the policy is written. | +| `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; a claim the token doesn't carry fails the filter closed (no rows on either surface), and a malformed template is rejected when the policy is written. | | `check` | map | insert | Required insert values (`_eq`, or `_in` for a claim-derived set; `_neq`/`_gt`/`_lt`, and setting both `_eq` and `_in`, are rejected when the policy is written). `_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 4b7d0508..79bb5e5e 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -281,7 +281,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): - `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. @@ -290,6 +290,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.) @@ -579,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 — 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`](/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 10e23453..1d25cbf5 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. @@ -85,15 +85,15 @@ 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. -- **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. +- **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`), 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 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 -- **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,8 +114,8 @@ 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. -- **validation.go** — `Validate(schema, data)` checks incoming JSON against the discovered schema: unknown fields, type compatibility, missing required columns, null handling. +- **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` 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 @@ -140,7 +140,10 @@ 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 `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 @@ -262,11 +265,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/docs/src/content/docs/sdk/streaming.md b/docs/src/content/docs/sdk/streaming.md index 88250196..ae75721d 100644 --- a/docs/src/content/docs/sdk/streaming.md +++ b/docs/src/content/docs/sdk/streaming.md @@ -108,9 +108,13 @@ string is read as UTC, an ECMAScript quirk 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). 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 -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/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/api/errors_test.go b/internal/api/errors_test.go index bdb2892f..af4a2439 100644 --- a/internal/api/errors_test.go +++ b/internal/api/errors_test.go @@ -193,7 +193,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 e0fe5042..4615602d 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 := testutil.NewTestSchemaRegistry(t, 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 := testutil.NewTestSchemaRegistry(t, 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 := testutil.NewTestSchemaRegistry(t, 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 := testutil.NewTestSchemaRegistry(t, 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 := testutil.NewTestSchemaRegistry(t, 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..5cf299aa 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 := "" @@ -78,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() + sub := stream.NewSubscriber(claims, h.Metrics) h.Hub.Add(topic, role, sub) defer h.Hub.Remove(topic, role, sub) @@ -95,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, data) + f, ok := project(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/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/discovery/validation.go b/internal/discovery/validation.go index 64b41074..7a3462a1 100644 --- a/internal/discovery/validation.go +++ b/internal/discovery/validation.go @@ -3,6 +3,7 @@ package discovery import ( "encoding/json" "fmt" + "strconv" "strings" ) @@ -149,6 +150,136 @@ 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 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" +} + +// 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 1dbbb08a..a3c92691 100644 --- a/internal/discovery/validation_test.go +++ b/internal/discovery/validation_test.go @@ -224,3 +224,104 @@ 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)) + }) + } +} + +// 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)) + }) + } +} + +// 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/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 6c58198d..1a47314d 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -1,11 +1,8 @@ package policy import ( - "encoding/json" "fmt" - "math/big" "regexp" - "strconv" "strings" "github.com/Wave-RF/WaveHouse/internal/chsql" @@ -65,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 @@ -192,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 @@ -227,51 +235,114 @@ 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) { +// 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"; an EMPTY Values matches no +// rows on either surface — an empty/unresolvable "in" set, or a scalar whose +// constant was unresolvable (an absent/null claim, a structured value, or one with +// no canonical form — see resolveTemplate/CanonicalScalar). +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 + // An unresolvable constant (ok=false from resolveTemplate) yields a predicate + // with NO values, which matches no rows on either surface (#385) — 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, scalar(col, "=", *f.Eq)) + } + if f.Neq != nil { + preds = append(preds, scalar(col, "!=", *f.Neq)) + } + if f.Gt != nil { + preds = append(preds, scalar(col, ">", *f.Gt)) + } + if f.Lt != nil { + preds = append(preds, scalar(col, "<", *f.Lt)) + } + if f.In != nil { + 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 col, f := range filters { + 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(col) - for _, c := range []struct { - op string - val *string - }{{"=", f.Eq}, {"!=", f.Neq}, {">", f.Gt}, {"<", f.Lt}} { - if c.val == nil { - continue - } - if val, ok := resolveTemplate(*c.val, claims); ok { - clauses = append(clauses, fmt.Sprintf("%s %s ?", qcol, c.op)) - params = append(params, val) - } else { - // An unresolvable claim fails closed: binding the '' it renders to - // would emit a real predicate against the empty string — `col != ''` - // or `col > ''` matches essentially every row, erasing the - // restriction (#385). Matching the _in branch below, a filter scoped - // to a claim the token doesn't carry matches no rows. - clauses = append(clauses, "1 = 0") - } - } - if f.In != nil { - vals := resolveInValues(*f.In, claims) - if len(vals) == 0 { + 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: + if len(p.Values) == 0 { + // An unresolvable claim fails closed: binding the '' it renders to + // would emit a real predicate against the empty string — `col != ''` + // or `col > ''` matches essentially every row, erasing the + // restriction (#385). Matching the _in branch above, a filter scoped + // to a claim the token doesn't carry matches no rows. + clauses = append(clauses, "1 = 0") + continue } + 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 canonical strings) 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 — absent/null, a structured value (JSON // object or array) rather than a scalar, or a numeric literal with no canonical @@ -346,162 +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()) - 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 -} - // 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 82961769..c825040f 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -553,6 +553,15 @@ func TestCanonicalScalar(t *testing.T) { {"null has no canonical form", nil, "", false}, {"object has no canonical form", map[string]any{"id": 1}, "", false}, {"array has no canonical form", []any{"a"}, "", false}, + // float64 is what a plain json.Unmarshal hands a hand-built claims map + // (production claims are json.Number via jwt.WithJSONNumber). At/past + // 2^53 the decode already collapsed neighboring integers, so any digits + // rendered could be another principal's ID — refuse; below it, render + // positionally, never fmt.Sprint's "1e+06" exponent spelling. + {"float64 below 2^53 renders positionally", float64(1_000_000), "1000000", true}, + {"float64 fraction", float64(1.5), "1.5", true}, + {"float64 at 2^53 refused — digits lost at decode", float64(1 << 53), "", false}, + {"float64 past 2^53 refused", float64(10000000000000001), "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1128,6 +1137,66 @@ func TestResolveFilters_InNumericElements_BindCanonically(t *testing.T) { assert.Equal(t, []any{"1", "12345678901234567890", "b"}, params) } +// TestResolveFilters_NumericClaimBinding pins the SQL surface of numeric claim +// rendering for a hand-built claims map: a json.Number claim binds its canonical +// exact digits; a float64 below 2^53 binds positionally (never the "1e+06" +// spelling ClickHouse integer columns reject); a float64 at or past 2^53 lost +// its digits at decode, so the predicate renders `1 = 0` — matching no rows, the +// same verdict RowVisible reaches in memory — alone or as one _in element. +func TestResolveFilters_NumericClaimBinding(t *testing.T) { + t.Parallel() + tmpl := "{{ jwt.tenant }}" + eq := map[string]Filter{"tenant_id": {Eq: &tmpl}} + + 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 := "{{ jwt.tenants }}" + clauses, params = resolveFilters(map[string]Filter{"tenant_id": {In: &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) +} + +// 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 new file mode 100644 index 00000000..3e936b90 --- /dev/null +++ b/internal/policy/rowfilter.go @@ -0,0 +1,278 @@ +package policy + +import ( + "encoding/json" + "strings" + "time" +) + +// 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 +} + +// 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 + +// 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/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). + ColumnOpaque ColumnKind = iota + // 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 + // 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 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 + // 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) + // 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 +// 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. +// +// cols maps column name → ColumnSpec, supplied by the caller from the table +// 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 +// 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. 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 { + 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, cols[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, 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], spec) + return ok && c == 0 + case "!=": + c, ok := compareScalar(raw, pred.Values[0], spec) + return ok && c != 0 + case ">": + c, ok := compareScalar(raw, pred.Values[0], spec) + return ok && c > 0 + case "<": + 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, spec); 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, +// 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. +// 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: + // 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 + } + // 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 + } + b, ok := spec.ParseTime(filterVal) + if !ok { + return 0, false + } + return a.Compare(b), true + case ColumnNumeric: + // 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 + } + a, ok := numericCanonical(rowVal) + if !ok { + return 0, false + } + b, ok := CanonicalNumericLiteral(filterVal) + if !ok { + return 0, false + } + // 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 { + 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 + } + return 0, false + default: + return 0, false // unknown future kind: refuse to compare, fail closed + } +} diff --git a/internal/policy/rowfilter_test.go b/internal/policy/rowfilter_test.go new file mode 100644 index 00000000..b913dcd3 --- /dev/null +++ b/internal/policy/rowfilter_test.go @@ -0,0 +1,543 @@ +package policy + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "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. +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") +} + +// 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]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)) + + 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]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]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)) +} + +// 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) + small := map[string]any{"amount": float64(9)} + big := map[string]any{"amount": float64(250)} + 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": 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") + + // 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]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)) +} + +// 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]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)) +} + +// 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]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") + + 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_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": 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") + + 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). +func TestRowVisible_NumericEquality_ExactBeyondFloat64(t *testing.T) { + t.Parallel() + 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") + 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 comparison keeps distinct IDs unequal for !=") +} + +// 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": intNumeric()} + perms := evalRowFilter(t, map[string]Filter{"amount": {Eq: new("100")}}, nil) + + 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") +} + +// 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: +// 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": intNumeric()} + 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") +} + +// 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 + 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]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") +} + +// TestRowFilter_UnresolvableClaim_NoRowsOnBothPaths pins the #457 fail-closed +// rule on BOTH read surfaces at once: a filter template whose claim the token +// doesn't carry renders the constant-false predicate on the query path AND +// withholds every row in the stream's in-memory evaluation. One Evaluate +// resolution drives both, so a claim-less token can never see zero rows on +// /v1/query yet every row on /v1/stream. HasRowFilter must stay true for the +// failed predicate — dropping it would put the role back on the unfiltered +// once-per-role fast path, the exact fail-open this test exists to prevent. +func TestRowFilter_UnresolvableClaim_NoRowsOnBothPaths(t *testing.T) { + t.Parallel() + noTenant := map[string]any{"role": "user"} // validly signed token, no tenant claim + tests := []struct { + name string + filter map[string]Filter + claims map[string]any + }{ + {"_eq", map[string]Filter{"tenant_id": {Eq: new("{{ jwt.tenant }}")}}, noTenant}, + {"_neq, the leak direction", map[string]Filter{"tenant_id": {Neq: new("{{ jwt.tenant }}")}}, noTenant}, + {"_gt", map[string]Filter{"tenant_id": {Gt: new("{{ jwt.tenant }}")}}, noTenant}, + {"_in with surrounding text", map[string]Filter{"tenant_id": {In: new("t-{{ jwt.tenant }}")}}, noTenant}, + { + "object claim in a scalar slot", + map[string]Filter{"tenant_id": {Eq: new("{{ jwt.meta }}")}}, + map[string]any{"meta": map[string]any{"tenant": "acme"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + perms := evalRowFilter(t, tt.filter, tt.claims) + assert.Equal(t, "1 = 0", perms.WhereClause, "query path: constant-false predicate") + assert.Empty(t, perms.WhereParams) + assert.True(t, perms.HasRowFilter(), "failed predicate must keep the stream on the per-subscriber path") + assert.False(t, perms.RowVisible(map[string]any{"tenant_id": "acme"}, nil), "stream path: every row withheld") + }) + } +} + +// 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/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 75a449b2..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() - s2 := NewSubscriber() + 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() + 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/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..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() + 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() + 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() + 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() + 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() + 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 e84821fa..ec99002a 100644 --- a/internal/stream/hub.go +++ b/internal/stream/hub.go @@ -3,27 +3,32 @@ package stream import ( "bytes" "encoding/json" + "io" "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 comparison degrades fail-closed (see columnSpecs) + metric *Metrics // nil-safe } // topicRoutes holds the per-role buckets subscribed to one topic. @@ -33,9 +38,12 @@ 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 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} } // Add registers sub to receive events for (topic, role), creating the role bucket @@ -100,10 +108,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. @@ -122,21 +132,139 @@ 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 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 colSpecs map[string]policy.ColumnSpec + specsResolved := 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). + // Push is fire-and-forget; Send itself counts any queue-full drop. + rb.bucket.Push(frame) + 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 !specsResolved { + colSpecs = h.columnSpecs(evt.TableName) + specsResolved = true + } for _, sub := range rb.bucket.Snapshot() { - 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 +// 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) columnSpecs(table string) map[string]policy.ColumnSpec { + if h.registry == nil { + return nil + } + schema := h.registry.Get(table) + if schema == nil { + return nil + } + 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): + // 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 { + spec.Numeric = NumericSpecOf(st) } + m[c.Name] = spec + case discovery.IsStringType(c.Type): + m[c.Name] = policy.ColumnSpec{Kind: policy.ColumnText} } } + 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 +// 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() + 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. @@ -149,50 +277,75 @@ 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) { - var evt ingest.EventMessage - decoded := json.Unmarshal(raw, &evt) == nil && evt.TableName != "" +// 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 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() - wire, ok := project(p, filter, role, &evt, raw, decoded) - if !ok { - return Frame{}, false + 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) + 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 specsFor != evt.TableName { + colSpecs = h.columnSpecs(evt.TableName) + specsFor = evt.TableName + } + if !h.rowAdmitted(p, role, &evt, claims, colSpecs) { + 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 } -// 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 +356,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 ddd353f8..db9418ac 100644 --- a/internal/stream/hub_test.go +++ b/internal/stream/hub_test.go @@ -3,25 +3,56 @@ package stream 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" + "github.com/Wave-RF/WaveHouse/internal/testutil" "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" ) -// rawEvent marshals an EventMessage the way the ingest path publishes it. -func rawEvent(t *testing.T, table, ts string, data map[string]any) []byte { +// 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). Tests whose +// guarantee depends on the decoded TYPE of a claim — the numeric cases — must +// build claims this way: a hand-built map once used string tenants here and +// passed while the production decode path failed open (#381 review). String and +// nested-object claims decode unchanged, so literal maps stay faithful there. +func jwtClaims(t *testing.T, claims map[string]any) map[string]any { t.Helper() - raw, err := json.Marshal(ingest.EventMessage{TableName: table, ReceivedTimestamp: ts, Data: data}) + 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 { + tb.Helper() + raw, err := json.Marshal(ingest.EventMessage{TableName: table, ReceivedTimestamp: ts, Data: data}) + require.NoError(tb, err) return raw } @@ -37,6 +68,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,10 +95,10 @@ 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() + a, b := NewSubscriber(nil, nil), NewSubscriber(nil, nil) hub.Add(topic, "public", a) hub.Add(topic, "public", b) @@ -85,11 +127,11 @@ 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() - blocked := NewSubscriber() + viewer := NewSubscriber(nil, nil) + blocked := NewSubscriber(nil, nil) hub.Add(topic, "viewer", viewer) hub.Add(topic, "blocked", blocked) @@ -109,12 +151,14 @@ func TestHub_ProjectsPerRole_ColumnFilterAndDenial(t *testing.T) { } } -// TestProject_FailsClosedOnUndecodedPayload is the #323 regression guard: with a -// policy configured (filter=true), a payload that did not decode to an -// EventMessage — so there is no table to evaluate policy against — must be -// dropped, never passed through unfiltered. Only the no-policy legacy passthrough -// (filter=false) may forward it, and invalid JSON is dropped either way. -func TestProject_FailsClosedOnUndecodedPayload(t *testing.T) { +// TestProjectColumns_FailsClosedOnUndecodedPayload is the #323 regression guard +// at the unit seam: with a policy configured (filter=true), a payload that did +// not decode to an EventMessage — so there is no table to evaluate policy +// against — must be dropped, never passed through unfiltered. Only the no-policy +// legacy passthrough (filter=false) may forward it, and invalid JSON is dropped +// either way. TestHub_PassthroughAndFailClosed drives the same rule end-to-end +// through Broadcast. +func TestProjectColumns_FailsClosedOnUndecodedPayload(t *testing.T) { t.Parallel() tests := []struct { name string @@ -129,7 +173,7 @@ func TestProject_FailsClosedOnUndecodedPayload(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - _, ok := project(nil, tt.filter, "viewer", nil, tt.raw, false) + _, _, ok := projectColumns(nil, tt.filter, "viewer", nil, tt.raw, false) assert.Equal(t, tt.wantOK, ok) }) } @@ -147,10 +191,10 @@ 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() + viewer, editor := NewSubscriber(nil, nil), NewSubscriber(nil, nil) hub.Add(topic, "viewer", viewer) hub.Add(topic, "editor", editor) @@ -174,10 +218,236 @@ 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. The third subscriber pins the #457 rule on this surface: a +// validly-signed token that doesn't carry the templated claim yields NO rows here, +// matching the constant-false predicate the query path binds for it. +func TestHub_RowFilter_PerSubscriberIsolation(t *testing.T) { + t.Parallel() + hub := NewHub(policy.NewMemoryStore(rowFilterPolicy()), nil, nil) + const topic = "ingest.clicks" + + 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) + + // 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) + // Unresolvable claim ⇒ no rows on the stream, matching the query path (#457). + assertNoFrame(t, noTenant) + + // 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) + assertNoFrame(t, noTenant) +} + +// 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 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{ + 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, nil) + 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") + + // 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 +// 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(map[string]any{"tenant": "acme"}, nil) + 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 := 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) + + 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. 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 := testutil.NewTestSchemaRegistry(t, []*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(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"})) + 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"]) + + // 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, 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) +} + +// 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, 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) - clicks, views := NewSubscriber(), NewSubscriber() + hub := NewHub(nil, nil, nil) + clicks, views := NewSubscriber(nil, nil), NewSubscriber(nil, nil) hub.Add("ingest.clicks", "public", clicks) hub.Add("ingest.views", "public", views) @@ -191,6 +461,12 @@ func TestHub_TopicIsolation(t *testing.T) { } } +// TestHub_PassthroughAndFailClosed drives the #323 fail-closed rule end-to-end +// through Broadcast: with a policy wired, a payload that did not decode to an +// EventMessage (no table to evaluate policy against) must be dropped, never +// passed through unfiltered; only the no-policy legacy passthrough may forward +// it. The unit seam is TestProjectColumns_FailsClosedOnUndecodedPayload; the +// empty-table_name half is pinned in TestHub_ReplayProjector. func TestHub_PassthroughAndFailClosed(t *testing.T) { t.Parallel() tests := []struct { @@ -219,9 +495,9 @@ 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() + sub := NewSubscriber(nil, nil) hub.Add(topic, "public", sub) hub.Broadcast(topic, []byte(tt.payload)) @@ -240,9 +516,9 @@ 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() + sub := NewSubscriber(nil, nil) hub.Add(topic, "public", sub) assert.Equal(t, 1, hub.Len(topic)) @@ -261,7 +537,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)})) }) @@ -278,28 +554,36 @@ func TestHub_SlowConsumerDropIncrementsMetric(t *testing.T) { otel.SetMeterProvider(savedMP) }) - hub := NewHub(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_ReplayFrame(t *testing.T) { +func TestHub_ReplayProjector(t *testing.T) { t.Parallel() p := &policy.Policy{ Tables: map[string]policy.TablePolicy{ "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 { @@ -313,7 +597,7 @@ func TestHub_ReplayFrame(t *testing.T) { // The empty-table_name half of #323, pinned on the fail-closed side: // {"table_name":"", …} decodes into an EventMessage but names no table to // evaluate policy against, so with a policy wired it must be dropped — - // same conjunct as the non-EventMessage case at hub.go's decoded flag. + // same conjunct as the non-EventMessage case in decodeEvent. { "empty table_name is dropped when policy is wired", "viewer", []byte(`{"table_name":"","data":{"page":"/home"}}`), false, @@ -322,7 +606,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, tt.raw) + f, ok := hub.ReplayProjector(tt.role, nil)(tt.raw) require.Equal(t, tt.want, ok) if !tt.want { return @@ -335,9 +619,42 @@ func TestHub_ReplayFrame(t *testing.T) { } } +// 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_ReplayProjector_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() + 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.ReplayProjector("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) + hub := NewHub(nil, nil, nil) const topic = "ingest.clicks" raw := rawEvent(t, "clicks", "t", map[string]any{"a": float64(1)}) @@ -347,7 +664,7 @@ func TestHub_ConcurrentAddRemoveBroadcast_Race(t *testing.T) { go func(role string) { defer wg.Done() for range 50 { - sub := NewSubscriber() + sub := NewSubscriber(nil, nil) hub.Add(topic, role, sub) hub.Broadcast(topic, raw) hub.Remove(topic, role, sub) @@ -358,6 +675,237 @@ 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}, nil) + 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 := testutil.NewTestSchemaRegistry(t, []*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" + + // 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")}), nil) + exact := NewSubscriber(jwtClaims(t, map[string]any{"tenant": json.Number("10000000000000001")}), nil) + 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_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, 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 +// 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"}, nil) + globex := NewSubscriber(map[string]any{"tenant": "globex"}, nil) + 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) + subs := make([]*Subscriber, n) + for i := range n { + tenant := "acme" + if i%2 == 1 { + tenant = "globex" + } + subs[i] = NewSubscriber(map[string]any{"tenant": tenant}, nil) + 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 { @@ -378,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/metrics.go b/internal/stream/metrics.go index 0059bb80..c9cd9990 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,15 @@ 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) +// 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 + } + 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 253980e5..1617673a 100644 --- a/internal/stream/subscriber.go +++ b/internal/stream/subscriber.go @@ -23,22 +23,77 @@ 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). 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 + // 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 // 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. -func NewSubscriber() *Subscriber { return newSubscriber(defaultSubscriberQueue) } +// event Hub, carrying the connection's JWT claims (nil for a tokenless caller), +// 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 +} + +// 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 { - 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 @@ -48,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 b5cfe8d0..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() + 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/e2e/sdk/streaming.test.ts b/tests/e2e/sdk/streaming.test.ts index 5f406e70..01af9836 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, 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 +25,25 @@ 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 (ResolvedPermissions.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 }}" } }, + }, + // '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 || {}), @@ -165,5 +187,108 @@ 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. + 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(); + usStream.close(); + 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..dffcaf8b --- /dev/null +++ b/tests/integration/rowfilter_narrowing_test.go @@ -0,0 +1,224 @@ +//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" + "github.com/Wave-RF/WaveHouse/internal/stream" +) + +// 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 +// 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) + return policy.ColumnSpec{Kind: policy.ColumnNumeric, Numeric: stream.NumericSpecOf(st)} +} + +// 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 +} 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),