Skip to content

fix(stream): apply policy row-filter per subscriber on SSE - #381

Open
taitelee wants to merge 9 commits into
mainfrom
sse-row-filter
Open

fix(stream): apply policy row-filter per subscriber on SSE#381
taitelee wants to merge 9 commits into
mainfrom
sse-row-filter

Conversation

@taitelee

@taitelee taitelee commented Jul 8, 2026

Copy link
Copy Markdown
Member

Summary

The Server-Sent Events stream applied a role's column allowlist but never its row-level filter, so a subscriber could receive rows the structured-query path would have hidden for that same role — a row-level-security bypass on the streaming surface. This applies the same row-filter on the stream:

  • The filter resolves once into predicates shared by both read paths — rendered to SQL on the query path, evaluated in memory (ResolvedPermissions.RowVisible) on the stream — so the two can't drift.
  • Fan-out is now claims-aware: a role without a filter keeps the once-per-role projection fast path unchanged (zero change for the public stream); a filtered role keeps the shared column projection but delivers each row only to the subscribers whose JWT claims admit it (evaluated against the full event). Replay does the same per connection.
  • Ordering predicates (_gt/_lt) are schema-informed — numeric columns compare numerically via the schema registry now wired into the hub — while equality/set (_eq/_neq/_in) stay exact. Ambiguous values fail closed; the best-effort ordering boundary is documented in access-control docs.

Related Issues

Closes #319

@taitelee
taitelee requested review from a team and EricAndrechek July 8, 2026 01:21
@taitelee taitelee moved this from Backlog to In progress in WaveHouse Task Board Jul 8, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code area/api HTTP handlers, routing, middleware area/query Structured query AST, SQL builder area/policy Access control policies (Hasura-style) area/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release labels Jul 8, 2026
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Security
    • Live SSE streams now apply role row-level filters per subscriber using each subscriber’s JWT claims, improving protection against row leaks.
    • Replay and gap-fill now use the same per-subscriber row visibility checks as live updates.
  • Documentation
    • Updated streaming access-control docs to clarify how row filters and ordering behave, and that resource limits are not enforced on the live event stream.
  • Bug Fixes / Tests
    • Improved permissions consistency between streaming and normal queries.
    • Added end-to-end coverage for subscriber-specific row scoping.

Walkthrough

This PR makes SSE live-stream delivery apply row-level policy filters per subscriber, using claims-aware policy resolution, schema-aware numeric comparisons, and replay-path filtering. It also updates wiring, tests, and documentation to match the new streaming behavior.

Changes

Streaming row-level security enforcement

Layer / File(s) Summary
Policy row-filter predicate resolution
internal/policy/policy.go
ResolvedPermissions caches resolved row-filter predicates; filter resolution is split into resolvePredicates and predicatesToSQL, replacing the monolithic resolveFilters.
Row visibility evaluation engine
internal/policy/rowfilter.go, internal/policy/rowfilter_test.go
Adds HasRowFilter/RowVisible with fail-closed matches, compareScalar, and scalarString helpers, covering equality, in-set, ordering, and multi-predicate cases.
Numeric type detection for ordering
internal/discovery/validation.go, internal/discovery/validation_test.go
Adds unwrapType and exported IsNumericType to distinguish numeric vs lexicographic comparisons.
Hub broadcast/replay row-filter integration
internal/stream/hub.go, internal/stream/subscriber.go, internal/api/stream.go
Hub gains a schema registry; Broadcast and ReplayFrame use projectColumns and per-subscriber RowVisible checks; Subscriber.SetClaims stores per-connection claims; Handle passes claims into the stream path.
NewHub constructor wiring and test updates
cmd/wavehouse/main.go, tests/integration/setup_test.go, internal/api/*_test.go, internal/stream/hub_test.go, go.mod
Updates NewHub/ReplayFrame call sites for the new signatures, refreshes stream/router tests, adds row-filter isolation and replay coverage, and bumps the Go toolchain version.
Documentation and changelog updates
CHANGELOG.md, docs/src/content/docs/access-control.mdx, docs/src/content/docs/architecture.md, tests/e2e/sdk/streaming.test.ts
Documents enforced row-level filtering, claims-aware column projection, and ordering predicate semantics on the live stream, and adds an end-to-end scoped-stream test.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StreamHandler
  participant Subscriber
  participant Hub
  participant Policy

  StreamHandler->>Subscriber: SetClaims(claims)
  StreamHandler->>Hub: register subscriber
  Hub->>Policy: Evaluate(role, claims)
  Policy-->>Hub: resolved rowFilter
  Hub->>Hub: projectColumns(role, event)
  Hub->>Hub: RowVisible(row, numericCols)
  Hub-->>Subscriber: deliver frame if visible
Loading

Possibly related issues

Possibly related PRs

  • Wave-RF/WaveHouse#172: Both PRs touch the authorization/policy layer that streaming relies on.
  • Wave-RF/WaveHouse#353: Both PRs modify the live SSE hub fan-out and projection path in internal/stream/hub.go.
  • Wave-RF/WaveHouse#358: Both PRs work on row-filter predicate handling and operator semantics in the policy layer.

Suggested reviewers: EricAndrechek

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The changes support issue #319 except for the unrelated Go toolchain update from 1.26.4 to 1.26.5. Remove the unrelated go.mod toolchain version change, or document its necessity for this streaming fix.
Docstring Coverage ⚠️ Warning Docstring coverage is 52.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation applies row filters to live and replayed SSE delivery, using subscriber claims and shared policy predicates as required by issue #319.
Title check ✅ Passed The title clearly and concisely identifies the main change: applying policy row filters per subscriber on SSE streams.
Description check ✅ Passed The description directly explains the SSE row-filter security fix, claims-aware fan-out, replay handling, and related implementation details.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sse-row-filter
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch sse-row-filter

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

📚 Docs preview is livehttps://c89f5022-wavehouse-docs.wave-rf.workers.dev

  • Commit2379bc4: refactor(policy): drop compiled row filter; evaluate per subscriber
  • Author@taitelee
  • Committed — 2026-07-09 08:39 (UTC-04:00)
  • Deployed — 2026-07-09 08:58 EDT

@github-code-quality

github-code-quality Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Go

Go

The overall coverage in the sse-row-filter branch remains at 90%, unchanged from the main branch.

Show a code coverage summary of the most impacted files.
File main 774faec sse-row-filter 2379bc4 +/-
internal/stream/hub.go 97% 95% -2%
internal/policy/policy.go 98% 98% 0%
internal/discov...y/validation.go 94% 94% 0%
internal/stream/subscriber.go 100% 100% 0%
internal/api/stream.go 55% 56% +1%
internal/policy/rowfilter.go 0% 85% +85%

Updated July 09, 2026 12:56 UTC
Code Coverage is in Public Preview. Learn more and provide us with your feedback.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/src/content/docs/access-control.mdx (1)

373-373: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the Live stream row in the enforcement table to include row-level filtering.

The "Where each rule is enforced" table at line 373 still reads "denied columns are masked from each event" with no mention of row-level filter, but this PR adds exactly that enforcement. The Structured read row (line 371) explicitly lists "row filter", so the table is now inconsistent with both the caution block below (lines 377–378) and the actual code (hub.go Broadcast calls RowVisible per subscriber). Per the docs code↔docs sync guideline, the table should reflect the changed behavior.

📝 Proposed fix to update the table row
-| 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 row-level `filter` predicates are evaluated per subscriber against their JWT claims (see caution below) |

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2efc09f7-0227-4f93-844b-1b18b486200f

📥 Commits

Reviewing files that changed from the base of the PR and between 774faec and 7756e11.

📒 Files selected for processing (17)
  • CHANGELOG.md
  • cmd/wavehouse/main.go
  • docs/src/content/docs/access-control.mdx
  • docs/src/content/docs/architecture.md
  • internal/api/errors_test.go
  • internal/api/router_test.go
  • internal/api/stream.go
  • internal/api/stream_test.go
  • internal/discovery/validation.go
  • internal/discovery/validation_test.go
  • internal/policy/policy.go
  • internal/policy/rowfilter.go
  • internal/policy/rowfilter_test.go
  • internal/stream/hub.go
  • internal/stream/hub_test.go
  • internal/stream/subscriber.go
  • tests/integration/setup_test.go
📜 Review details
⏰ Context from checks skipped due to timeout. (5)
  • GitHub Check: E2E tests
  • GitHub Check: Docs build
  • GitHub Check: Coverage
  • GitHub Check: Lint
  • GitHub Check: Analyze (go)
⚠️ CI failures not shown inline (2)

GitHub Actions: PR housekeeping / 0_PR housekeeping.txt: fix(stream): apply policy row-filter per subscriber on SSE

Conclusion: failure

View job details

##[group]Run # Single source of truth for the rule: scripts/lint-pr-title.sh — the
 �[36;1m# Single source of truth for the rule: scripts/lint-pr-title.sh — the�[0m
 �[36;1m# SAME validator the local agent gate runs (.claude/hooks/agent-bash-gate.sh),�[0m
 �[36;1m# so CI and local can't drift. The checkout above is ref: main, so this is�[0m
 �[36;1m# always the default-branch script. Dependabot's grouped-update titles�[0m
 �[36;1m# routinely exceed the 72-char subject cap and the format isn't�[0m
 �[36;1m# configurable, so Dependabot PRs are exempt from the length check�[0m
 �[36;1m# (the format check still applies).�[0m
 �[36;1mif [[ "$PR_AUTHOR" == "dependabot[bot]" || "$PR_AUTHOR" == "app/dependabot" ]]; then�[0m
 �[36;1m  export PR_TITLE_SKIP_LENGTH=1�[0m
 �[36;1mfi�[0m
 �[36;1m�[0m
 �[36;1mif reason=$(bash scripts/lint-pr-title.sh "$PR_TITLE" 2>&1); then�[0m
 �[36;1m  echo "passed=true" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  echo "PR title OK: $PR_TITLE"�[0m
 �[36;1melse�[0m
 �[36;1m  echo "passed=false" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  printf '%s\n' "$reason"�[0m
 �[36;1m  echo "::error::$(printf '%s' "$reason" | head -1)"�[0m

GitHub Actions: PR housekeeping / PR housekeeping: fix(stream): apply policy row-filter per subscriber on SSE

Conclusion: failure

View job details

##[group]Run # Single source of truth for the rule: scripts/lint-pr-title.sh — the
 �[36;1m# Single source of truth for the rule: scripts/lint-pr-title.sh — the�[0m
 �[36;1m# SAME validator the local agent gate runs (.claude/hooks/agent-bash-gate.sh),�[0m
 �[36;1m# so CI and local can't drift. The checkout above is ref: main, so this is�[0m
 �[36;1m# always the default-branch script. Dependabot's grouped-update titles�[0m
 �[36;1m# routinely exceed the 72-char subject cap and the format isn't�[0m
 �[36;1m# configurable, so Dependabot PRs are exempt from the length check�[0m
 �[36;1m# (the format check still applies).�[0m
 �[36;1mif [[ "$PR_AUTHOR" == "dependabot[bot]" || "$PR_AUTHOR" == "app/dependabot" ]]; then�[0m
 �[36;1m  export PR_TITLE_SKIP_LENGTH=1�[0m
 �[36;1mfi�[0m
 �[36;1m�[0m
 �[36;1mif reason=$(bash scripts/lint-pr-title.sh "$PR_TITLE" 2>&1); then�[0m
 �[36;1m  echo "passed=true" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  echo "PR title OK: $PR_TITLE"�[0m
 �[36;1melse�[0m
 �[36;1m  echo "passed=false" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  printf '%s\n' "$reason"�[0m
 �[36;1m  echo "::error::$(printf '%s' "$reason" | head -1)"�[0m
🧰 Additional context used
📓 Path-based instructions (2)
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*_test.go: Write tests in table-driven form with t.Run(tt.name, ...) for multiple cases.
Use shared mocks from internal/testutil/ instead of ad-hoc mocks in tests.
Use the repo’s JWT, schema, policy, pipes, and JSON response test helpers (testutil.MakeJWT, testutil.MakeExpiredJWT, NewTestSchemaRegistry, policy.NewMemoryStore, pipes.NewMemoryStore, AssertJSONResponse, AssertJSONContains) where applicable.
Every new function should have corresponding test cases, and new code should aim for 80%+ coverage.

Files:

  • internal/api/errors_test.go
  • tests/integration/setup_test.go
  • internal/api/stream_test.go
  • internal/api/router_test.go
  • internal/discovery/validation_test.go
  • internal/policy/rowfilter_test.go
  • internal/stream/hub_test.go
docs/src/content/docs/**/*.{md,mdx}

📄 CodeRabbit inference engine (AGENTS.md)

Documentation prose under the Starlight docs site must stay accurate against code, include runnable examples where relevant, and reflect code↔docs sync for changed behavior.

Files:

  • docs/src/content/docs/access-control.mdx
  • docs/src/content/docs/architecture.md
🧠 Learnings (5)
📚 Learning: 2026-05-20T01:02:00.784Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 164
File: internal/api/router_test.go:289-350
Timestamp: 2026-05-20T01:02:00.784Z
Learning: In WaveHouse’s internal API tests (files matching internal/api/**/*_test.go), follow the existing separation-of-concerns convention for testing the RequireRole middleware: inject `ContextKeyRole` directly into the request `context.Context` instead of using `testutil.MakeJWT`/JWT-driven flows. Do not refactor role-gate tests to use JWT tokens—JWT parsing and token handling are covered separately in `middleware_test.go` (the dedicated JWT parsing tests), and mixing those concerns would expand the failure surface and reduce isolation.

Applied to files:

  • internal/api/errors_test.go
  • internal/api/stream_test.go
  • internal/api/router_test.go
📚 Learning: 2026-05-23T01:23:59.268Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 174
File: internal/api/ingest_test.go:111-111
Timestamp: 2026-05-23T01:23:59.268Z
Learning: In WaveHouse Go tests in internal/api/**/*_test.go, use internal/testutil.AssertJSONErrorResponse(t, w) for HTTP error-path JSON assertions. Do not use (or reintroduce) package-local assertJSONErrorResponse helpers. AssertJSONErrorResponse verifies the response Content-Type is application/json, includes the X-Content-Type-Options: nosniff header, and that the JSON body contains an "error" field.

Applied to files:

  • internal/api/errors_test.go
  • internal/api/stream_test.go
  • internal/api/router_test.go
📚 Learning: 2026-06-26T12:23:22.696Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 346
File: internal/stream/subscriber_test.go:9-28
Timestamp: 2026-06-26T12:23:22.696Z
Learning: In this Go repository, prefer table-driven tests (e.g., `[]struct{...}` with `t.Run(...)`) only for tests that cover multiple scenarios/inputs and can be cleanly enumerated. Do not artificially rewrite a clear single-scenario sequential behavioral-flow test into a table-driven form just to fit the pattern; if there’s only one meaningful scenario, keep the test as a straightforward linear flow (as in `TestSubscriber_SendDeliversThenDropsWhenFull`).

Applied to files:

  • internal/api/errors_test.go
  • tests/integration/setup_test.go
  • internal/api/stream_test.go
  • internal/api/router_test.go
  • internal/discovery/validation_test.go
  • internal/policy/rowfilter_test.go
  • internal/stream/hub_test.go
📚 Learning: 2026-07-07T12:38:12.052Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 378
File: internal/auth/auth.go:119-132
Timestamp: 2026-07-07T12:38:12.052Z
Learning: In this repo, do not add or recommend logging/tracing client IP addresses using naive or untrusted sources (e.g., `r.RemoteAddr` or directly trusting/deriving `X-Forwarded-For`) anywhere in the Go codebase. `middleware.RealIP` was removed due to IP-spoofing risks, and proper trusted-proxy-aware client-IP handling is intentionally deferred to issue `#333`. During code review, if proposed changes would record client IPs (including in audit paths such as `internal/auth/auth.go`), reject/redirect until `#333` lands with correct trusted-proxy configuration and safeguards.

Applied to files:

  • internal/api/errors_test.go
  • cmd/wavehouse/main.go
  • internal/discovery/validation.go
  • tests/integration/setup_test.go
  • internal/api/stream.go
  • internal/api/stream_test.go
  • internal/api/router_test.go
  • internal/discovery/validation_test.go
  • internal/policy/policy.go
  • internal/policy/rowfilter.go
  • internal/stream/subscriber.go
  • internal/policy/rowfilter_test.go
  • internal/stream/hub.go
  • internal/stream/hub_test.go
📚 Learning: 2026-06-10T15:01:09.027Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 312
File: docs/src/content/docs/development.md:0-0
Timestamp: 2026-06-10T15:01:09.027Z
Learning: In this repo’s Markdown review (all .md files), do not flag capitalization/style issues for literal paths starting with ".github/" (or any substring that is a path beginning with ".github/"). Treat ".github" as the correct lowercase dotfile directory name, even when it appears inside prose or code spans; automated checks such as LanguageTool’s "(GITHUB)" rule commonly produce false positives for this literal filesystem path.

Applied to files:

  • docs/src/content/docs/architecture.md
  • CHANGELOG.md
🔇 Additional comments (20)
CHANGELOG.md (1)

34-34: LGTM!

docs/src/content/docs/access-control.mdx (1)

377-378: LGTM!

docs/src/content/docs/architecture.md (1)

88-88: LGTM!

cmd/wavehouse/main.go (1)

295-295: LGTM!

tests/integration/setup_test.go (1)

315-315: LGTM!

internal/api/errors_test.go (1)

197-197: LGTM!

internal/api/router_test.go (1)

286-286: LGTM!

Also applies to: 342-342, 414-414, 475-475, 526-526, 551-551, 648-648

internal/api/stream_test.go (1)

25-25: LGTM!

Also applies to: 53-53, 78-78, 117-117

internal/stream/hub_test.go (2)

41-51: LGTM!


68-68: LGTM!

Also applies to: 100-100, 136-136, 288-288, 331-331, 352-352, 373-373, 390-390, 411-411, 440-440

internal/policy/policy.go (1)

64-82: LGTM!

Also applies to: 197-208, 228-313

internal/policy/rowfilter.go (2)

13-15: LGTM!


17-103: 🔒 Security & Privacy

Lexicographic fallback is intentional NewHub is already wired with a schema registry in cmd/wavehouse/main.go, and numericCols deliberately falls back to string comparison when schema information is unavailable. That makes >/< best-effort by design, so this isn’t a security bug; the doc comment is the part that should be softened.

			> Likely an incorrect or invalid review comment.
internal/policy/rowfilter_test.go (1)

1-108: LGTM!

internal/discovery/validation.go (1)

51-69: LGTM!

Also applies to: 152-158

internal/discovery/validation_test.go (1)

227-255: LGTM!

internal/api/stream.go (1)

45-51: LGTM!

Also applies to: 85-85, 103-103

internal/stream/hub.go (2)

25-31: LGTM!

Also applies to: 43-45


185-198: LGTM!

Also applies to: 218-233, 245-277

internal/stream/subscriber.go (1)

26-31: LGTM!

Also applies to: 51-55

Comment thread internal/stream/hub_test.go
Comment thread internal/stream/hub_test.go
Comment thread internal/stream/hub.go
@github-project-automation github-project-automation Bot moved this from In progress to In review in WaveHouse Task Board Jul 8, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 8, 2026
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Jul 8, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c2faf18f-1e6f-44b3-92f4-aa3152c98c0c

📥 Commits

Reviewing files that changed from the base of the PR and between d534a25 and 8987480.

📒 Files selected for processing (6)
  • go.mod
  • internal/policy/policy.go
  • internal/policy/rowfilter.go
  • internal/policy/rowfilter_test.go
  • internal/stream/hub.go
  • internal/stream/hub_test.go
📜 Review details
⏰ Context from checks skipped due to timeout. (7)
  • GitHub Check: Integration tests
  • GitHub Check: E2E tests
  • GitHub Check: Docs build
  • GitHub Check: Unit tests
  • GitHub Check: Coverage
  • GitHub Check: Lint
  • GitHub Check: Analyze (go)
⚠️ CI failures not shown inline (2)

GitHub Actions: PR housekeeping / PR housekeeping: fix(stream): apply policy row-filter per subscriber on SSE

Conclusion: failure

View job details

##[group]Run # Single source of truth for the rule: scripts/lint-pr-title.sh — the
 �[36;1m# Single source of truth for the rule: scripts/lint-pr-title.sh — the�[0m
 �[36;1m# SAME validator the local agent gate runs (.claude/hooks/agent-bash-gate.sh),�[0m
 �[36;1m# so CI and local can't drift. The checkout above is ref: main, so this is�[0m
 �[36;1m# always the default-branch script. Dependabot's grouped-update titles�[0m
 �[36;1m# routinely exceed the 72-char subject cap and the format isn't�[0m
 �[36;1m# configurable, so Dependabot PRs are exempt from the length check�[0m
 �[36;1m# (the format check still applies).�[0m
 �[36;1mif [[ "$PR_AUTHOR" == "dependabot[bot]" || "$PR_AUTHOR" == "app/dependabot" ]]; then�[0m
 �[36;1m  export PR_TITLE_SKIP_LENGTH=1�[0m
 �[36;1mfi�[0m
 �[36;1m�[0m
 �[36;1mif reason=$(bash scripts/lint-pr-title.sh "$PR_TITLE" 2>&1); then�[0m
 �[36;1m  echo "passed=true" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  echo "PR title OK: $PR_TITLE"�[0m
 �[36;1melse�[0m
 �[36;1m  echo "passed=false" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  printf '%s\n' "$reason"�[0m
 �[36;1m  echo "::error::$(printf '%s' "$reason" | head -1)"�[0m

GitHub Actions: PR housekeeping / 0_PR housekeeping.txt: fix(stream): apply policy row-filter per subscriber on SSE

Conclusion: failure

View job details

##[group]Run # Single source of truth for the rule: scripts/lint-pr-title.sh — the
 �[36;1m# Single source of truth for the rule: scripts/lint-pr-title.sh — the�[0m
 �[36;1m# SAME validator the local agent gate runs (.claude/hooks/agent-bash-gate.sh),�[0m
 �[36;1m# so CI and local can't drift. The checkout above is ref: main, so this is�[0m
 �[36;1m# always the default-branch script. Dependabot's grouped-update titles�[0m
 �[36;1m# routinely exceed the 72-char subject cap and the format isn't�[0m
 �[36;1m# configurable, so Dependabot PRs are exempt from the length check�[0m
 �[36;1m# (the format check still applies).�[0m
 �[36;1mif [[ "$PR_AUTHOR" == "dependabot[bot]" || "$PR_AUTHOR" == "app/dependabot" ]]; then�[0m
 �[36;1m  export PR_TITLE_SKIP_LENGTH=1�[0m
 �[36;1mfi�[0m
 �[36;1m�[0m
 �[36;1mif reason=$(bash scripts/lint-pr-title.sh "$PR_TITLE" 2>&1); then�[0m
 �[36;1m  echo "passed=true" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  echo "PR title OK: $PR_TITLE"�[0m
 �[36;1melse�[0m
 �[36;1m  echo "passed=false" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  printf '%s\n' "$reason"�[0m
 �[36;1m  echo "::error::$(printf '%s' "$reason" | head -1)"�[0m
🧰 Additional context used
📓 Path-based instructions (1)
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*_test.go: Write tests in table-driven form with t.Run(tt.name, ...) for multiple cases.
Use shared mocks from internal/testutil/ instead of ad-hoc mocks in tests.
Use the repo’s JWT, schema, policy, pipes, and JSON response test helpers (testutil.MakeJWT, testutil.MakeExpiredJWT, NewTestSchemaRegistry, policy.NewMemoryStore, pipes.NewMemoryStore, AssertJSONResponse, AssertJSONContains) where applicable.
Every new function should have corresponding test cases, and new code should aim for 80%+ coverage.

Files:

  • internal/policy/rowfilter_test.go
  • internal/stream/hub_test.go
🧠 Learnings (2)
📚 Learning: 2026-07-07T12:38:12.052Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 378
File: internal/auth/auth.go:119-132
Timestamp: 2026-07-07T12:38:12.052Z
Learning: In this repo, do not add or recommend logging/tracing client IP addresses using naive or untrusted sources (e.g., `r.RemoteAddr` or directly trusting/deriving `X-Forwarded-For`) anywhere in the Go codebase. `middleware.RealIP` was removed due to IP-spoofing risks, and proper trusted-proxy-aware client-IP handling is intentionally deferred to issue `#333`. During code review, if proposed changes would record client IPs (including in audit paths such as `internal/auth/auth.go`), reject/redirect until `#333` lands with correct trusted-proxy configuration and safeguards.

Applied to files:

  • internal/stream/hub.go
  • internal/policy/rowfilter.go
  • internal/policy/policy.go
  • internal/policy/rowfilter_test.go
  • internal/stream/hub_test.go
📚 Learning: 2026-06-26T12:23:22.696Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 346
File: internal/stream/subscriber_test.go:9-28
Timestamp: 2026-06-26T12:23:22.696Z
Learning: In this Go repository, prefer table-driven tests (e.g., `[]struct{...}` with `t.Run(...)`) only for tests that cover multiple scenarios/inputs and can be cleanly enumerated. Do not artificially rewrite a clear single-scenario sequential behavioral-flow test into a table-driven form just to fit the pattern; if there’s only one meaningful scenario, keep the test as a straightforward linear flow (as in `TestSubscriber_SendDeliversThenDropsWhenFull`).

Applied to files:

  • internal/policy/rowfilter_test.go
  • internal/stream/hub_test.go
🔇 Additional comments (8)
internal/policy/policy.go (2)

111-175: LGTM!

Also applies to: 177-233


235-260: LGTM!

Also applies to: 262-461

internal/policy/rowfilter.go (1)

78-139: LGTM!

internal/policy/rowfilter_test.go (1)

111-167: LGTM!

Also applies to: 169-232

internal/stream/hub.go (2)

142-171: LGTM!

Also applies to: 221-236


172-173: 🩺 Stability & Availability

No race hereSubscriber.claims is set once via SetClaims before Hub.Add and documented as read-only for the subscriber’s lifetime, so Broadcast’s read does not have a concurrent writer.

			> Likely an incorrect or invalid review comment.
internal/stream/hub_test.go (1)

22-29: LGTM!

Also applies to: 188-214, 440-464, 519-526, 530-545

go.mod (1)

3-3: LGTM!

Comment thread internal/stream/hub_test.go Outdated
@github-actions github-actions Bot added the area/sdk TypeScript SDK (clients/ts/) label Jul 8, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 8, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 8, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/stream/hub_test.go (1)

11-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the shared schema test helper.

This test constructs the registry with discovery.NewSchemaRegistryFromMap directly. Replace it with internal/testutil.NewTestSchemaRegistry to keep schema setup consistent with the repository’s test infrastructure. (github.com)

As per coding guidelines, use the repo’s shared schema test helper (NewTestSchemaRegistry) where applicable.

Also applies to: 254-284

Source: Coding guidelines

tests/e2e/sdk/streaming.test.ts (1)

133-182: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the cross-country absence checks deterministic.

The test waits for each matching event, then immediately checks the other subscriber’s array. A leaked event can still be in flight when some(...) runs, so the test can pass before observing the leak. Insert a unique per-country barrier event after the two target events, wait for both subscribers to receive their barriers, then assert that each stream lacks the other country’s target ID. (github.com)


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 88138154-07d6-437e-8dfc-6c2b5b2e0acc

📥 Commits

Reviewing files that changed from the base of the PR and between dd38086 and 2379bc4.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • docs/src/content/docs/architecture.md
  • internal/policy/policy.go
  • internal/policy/rowfilter.go
  • internal/policy/rowfilter_test.go
  • internal/stream/hub.go
  • internal/stream/hub_test.go
  • tests/e2e/sdk/streaming.test.ts
💤 Files with no reviewable changes (1)
  • internal/policy/rowfilter_test.go
📜 Review details
🧰 Additional context used
📓 Path-based instructions (2)
docs/src/content/docs/**/*.{md,mdx}

📄 CodeRabbit inference engine (AGENTS.md)

Documentation prose under the Starlight docs site must stay accurate against code, include runnable examples where relevant, and reflect code↔docs sync for changed behavior.

Files:

  • docs/src/content/docs/architecture.md
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*_test.go: Write tests in table-driven form with t.Run(tt.name, ...) for multiple cases.
Use shared mocks from internal/testutil/ instead of ad-hoc mocks in tests.
Use the repo’s JWT, schema, policy, pipes, and JSON response test helpers (testutil.MakeJWT, testutil.MakeExpiredJWT, NewTestSchemaRegistry, policy.NewMemoryStore, pipes.NewMemoryStore, AssertJSONResponse, AssertJSONContains) where applicable.
Every new function should have corresponding test cases, and new code should aim for 80%+ coverage.

Files:

  • internal/stream/hub_test.go
🧠 Learnings (3)
📚 Learning: 2026-06-10T15:01:09.027Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 312
File: docs/src/content/docs/development.md:0-0
Timestamp: 2026-06-10T15:01:09.027Z
Learning: In this repo’s Markdown review (all .md files), do not flag capitalization/style issues for literal paths starting with ".github/" (or any substring that is a path beginning with ".github/"). Treat ".github" as the correct lowercase dotfile directory name, even when it appears inside prose or code spans; automated checks such as LanguageTool’s "(GITHUB)" rule commonly produce false positives for this literal filesystem path.

Applied to files:

  • CHANGELOG.md
  • docs/src/content/docs/architecture.md
📚 Learning: 2026-07-07T12:38:12.052Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 378
File: internal/auth/auth.go:119-132
Timestamp: 2026-07-07T12:38:12.052Z
Learning: In this repo, do not add or recommend logging/tracing client IP addresses using naive or untrusted sources (e.g., `r.RemoteAddr` or directly trusting/deriving `X-Forwarded-For`) anywhere in the Go codebase. `middleware.RealIP` was removed due to IP-spoofing risks, and proper trusted-proxy-aware client-IP handling is intentionally deferred to issue `#333`. During code review, if proposed changes would record client IPs (including in audit paths such as `internal/auth/auth.go`), reject/redirect until `#333` lands with correct trusted-proxy configuration and safeguards.

Applied to files:

  • internal/policy/policy.go
  • internal/policy/rowfilter.go
  • internal/stream/hub.go
  • internal/stream/hub_test.go
📚 Learning: 2026-06-26T12:23:22.696Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 346
File: internal/stream/subscriber_test.go:9-28
Timestamp: 2026-06-26T12:23:22.696Z
Learning: In this Go repository, prefer table-driven tests (e.g., `[]struct{...}` with `t.Run(...)`) only for tests that cover multiple scenarios/inputs and can be cleanly enumerated. Do not artificially rewrite a clear single-scenario sequential behavioral-flow test into a table-driven form just to fit the pattern; if there’s only one meaningful scenario, keep the test as a straightforward linear flow (as in `TestSubscriber_SendDeliversThenDropsWhenFull`).

Applied to files:

  • internal/stream/hub_test.go
🔇 Additional comments (8)
internal/policy/policy.go (2)

128-206: LGTM!


239-261: 📐 Maintainability & Code Quality

No change needed. Predicate resolution and rendering already have end-to-end Evaluate coverage for resolved claim values, empty _in values, combined predicates, and matching WhereClause/WhereParams.

internal/policy/rowfilter.go (1)

40-44: LGTM!

internal/stream/hub.go (1)

22-22: LGTM!

Also applies to: 40-44, 109-114, 115-199, 203-232, 274-276, 282-293

internal/stream/hub_test.go (1)

21-26: LGTM!

Also applies to: 41-51, 68-68, 100-100, 136-136, 163-253, 286-288, 331-331, 352-352, 373-373, 390-390, 411-411, 425-425, 438-462, 464-466

CHANGELOG.md (1)

34-34: LGTM!

docs/src/content/docs/architecture.md (1)

88-88: LGTM!

tests/e2e/sdk/streaming.test.ts (1)

3-10: LGTM!

Also applies to: 28-39

Comment thread internal/stream/hub.go
Comment on lines +241 to +242
// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Name the claims-bound permissions in this comment.

projectColumns returns the claims-independent perms. The callers resolve subPerms with each subscriber’s claims before calling RowVisible. Change perms.RowVisible to subPerms.RowVisible, or use “the per-subscriber resolved permissions,” to prevent future code from reusing the nil-claims permissions for row visibility. (github.com)

@EricAndrechek

Copy link
Copy Markdown
Member

Two doc-sync findings that land on lines this PR doesn't touch, so they can't be line-anchored as review comments.

docs/src/content/docs/api.md:542 — the GET /v1/stream note still reads:

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.

That enumeration is now incomplete in a security-relevant way. Hub.Broadcast calls policy.Evaluate(...) + RowVisible(evt.Data, numericCols) per subscriber whenever perms.HasRowFilter(), and ReplayFrame does the same per connection for gap-fill — so a reader of the endpoint reference alone would conclude rows are not filtered, which is exactly what access-control.mdx said before this PR. AGENTS.md §Documentation Sync maps "add/modify API endpoint" to api.md, and the file isn't in this diff at all.

Worth noting here too that the stream now depends on claims, so a connection authenticated via ?token= supplies the claims the filter binds against.

docs/src/content/docs/access-control.mdx:181 — the canonical section for the feature still describes filter as a SQL-only mechanism:

filter restricts which rows a role can read by injecting a WHERE clause into the generated SQL.

The column section immediately above it (line 177) already names both read surfaces — "the structured-query and live-stream paths defer to the same per-column decision" — and after this PR the row filter has the identical property: one resolvePredicates call in Evaluate feeds both predicatesToSQL (query) and RowVisible (stream). The row-filter paragraph should say so, rather than leaving the enforcement caution further down the page as the only place it's mentioned.

@EricAndrechek EricAndrechek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work. Reviewing this PR took a lot longer than I expected/wanted it to – it really made me realize how complex our policy engine and stuff is starting to become... I originally had a BUNCH of things I was adding to this PR review about policy stuff I didn't like, but I think it's more important to get this security fixing PR merged and then to refactor and make policy more maintainable separately. Also some efficiency/perf type things, especially in the claim evaluation predicate stuff, SQL building, etc – like I noticed in this review just how much of that is wasteful recomputation and could be memoized or smth, and how much happens on the hot path, etc., but same idea: let's tackle that later in another PR so this one can just get shipped. I also forgot about our discussion about the row policy logic and how it being done correctly would technically require a CH SQL engine executing, which is what the query path does, but iirc what we decided on and you built was to just do a best effort of it now that fails closed until we can figure that out so we at least don't have a gaping security hole, as we'd rather that failed closed behavior occasionally accidentally blocking data from being streamed that should have been allowed as opposed to the inverse.

My notes on this PR include:

  • a case in policy/rowfilter.go where I think we actually fail open? Would love a test case to confirm that first, and then if my theory is correct, for it be closed and test case to enforce it so it can't regress. So like "9" > "100" being a test that should NOT pass I think, right? Also need to make sure _neq properly works and fails closed too for instance, where like UUIDs that have different caps applied but that CH might see as the same etc need to be equal but the current code would pass them through to trivial string comparisons I think.
  • a reference to some of the perf / memoization stuff in the code to a new issue I opened (#435) for it just to document
  • a small req for us to add in some o11y for when rls filters/withholds data and some other minor o11y fixes
  • test case expansion, specifically for the hub stuff: I don't think that the dual goroutine interaction was properly being tested with race coverage for sub claims?
  • a minor immutability req on stream/subscriber.go's claims since those (I don't think need to be mutable? Feel free to pushback if I missed something though...) shouldn't change/hot-reload or at least have no mechanism to mid-stream right now (which kind of leads into the broader #239 issue...)
  • a bunch of smaller stale comment or docs type things – not sure if you have been or not, but HIGHLY recommend/suggest you get in the practice of running /prepush or /docs-review for just docs (or /pr-review-locally for other PR reviews) in claude on your local worktree/branch before requesting human review etc as it does a good job at catching low hanging fruit, especially these sorts of doc inconsistencies for you

return false
}
for _, pred := range p.rowFilter {
if !pred.matches(row, numericCols[pred.Column]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

numericCols[pred.Column] collapses three distinct states into false: a known text column, a column absent from the schema, and no schema at allHub.numericCols returns nil when the registry is nil or when registry.Get(table) misses. The first is correct. The other two silently downgrade _gt/_lt to lexicographic comparison, which admits rows ClickHouse would exclude: "9" > "100" is true as text, false as numbers.

This contradicts the contract stated at lines 31–33 of this file — "Every ambiguous or uncomparable case fails closed — the row is hidden, never leaked" — and rowfilter_test.go:64 currently pins the leak as expected behavior.

The no-schema state is reachable in production, not just in the nil-registry test hub: cmd/wavehouse/main.go:198 treats a failed boot-time registry.Refresh as non-fatal and serves while retrying in the background, so every table reads as unknown for that window. A table dropped or renamed since the replayed events were published lands in the same place.

func TestHub_ConcurrentAddRemoveBroadcast_Race(t *testing.T) {
t.Parallel()
hub := NewHub(nil, nil)
hub := NewHub(nil, nil, nil)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only test that drives the hub from multiple goroutines, and it builds a passthrough hub. With a nil policy store projectColumns returns a nil perms, so HasRowFilter() is false and the new per-subscriber sub.claims read at hub.go:169 never executes here.

The tests that do reach that line (TestHub_RowFilter_*) call SetClaims, Add and Broadcast from a single goroutine, so the cross-goroutine read this PR introduces is never observed by the race detector anywhere in the suite. -race covers unit and integration only (Makefile:666, :680), and the e2e binary is built -cover -covermode=atomic under CGO_ENABLED=0 (scripts/build.sh), so the new e2e row-filter coverage doesn't close the gap either.

:::caution[Live streams enforce access, not row filters]
SSE subscribers are checked for table-level `select` permission and have denied columns stripped from each event, but the row-level `filter` predicates and the resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) are a property of the SQL query path and are **not** applied to the live event stream. If a role must never observe another tenant's rows in real time, don't grant it stream access to a shared table — scope the data at the table level.
:::caution[Live streams enforce column and row policy, but not resource limits]
SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Equality and set predicates (`_eq`, `_neq`, `_in`) — the usual tenant/user scoping — are enforced exactly: numeric ties resolve at full precision (an ID beyond `float64`'s 2^53 never falsely matches a neighbor) and an unparseable or `NaN` operand withholds the row. One representation caveat: a `Bool` (or `DateTime`) value compares by its canonical text form, without ClickHouse's cross-representation coercion — write the filter value the way your events carry it (`true`, not `1`). Ordering predicates (`_gt`, `_lt`) are best-effort: the stream compares numeric columns numerically (matching ClickHouse) but lacks ClickHouse's full per-type coercion, so an ordering filter on an exotic type (a `Decimal`/`Int128` beyond `float64` precision, or a `DateTime` ingested as a Unix number) can admit or withhold a row the query path wouldn't. The resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) remain a property of the SQL query path and are **not** applied to the live event stream; if those caps are part of a role's isolation story, don't rely on them over the stream.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with the representation caveat.

Scope. Numeric comparison is selected only when discovery.IsNumericType is true, and that covers Int*/UInt*/Float*/Decimal* only (internal/discovery/validation.go:161). So every other type compares as text — Enum, UUID, IPv4/IPv6, Date, String — not just Bool and DateTime as written here.

Direction. The failure isn't uniformly fail-closed. A representation mismatch withholds the row under _eq/_in, but under _neq the text compare returns "not equal" and admits a row the query path would have excluded — an uppercase UUID carried in a claim, or 1 for a Bool.

The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)) lives next to the keepalive primitives it shares. One abstraction per file.

- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The `(topic, role)` key is sufficient because column visibility derives only from the role+table policy entry, never from JWT claims (claims feed only the row-level `WHERE`/`CHECK`, which the stream path does not apply). `ReplayFrame` shares the same projection for the handler's per-connection gap-fill.
- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(topic, role)`; `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the numeric-aware comparison seeded from the schema registry). This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayFrame` shares the same projection and per-connection row check for the handler's gap-fill.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bullet is updated, but the Streaming Path flow block at lines 261–263 still reads "Per-role policy filtering (historical + live): denied tables skipped, denied columns stripped. Live projection runs once per role (Hub.Broadcast)". It doesn't mention row filtering, and "once per role" now holds only for roles without a filter. The two descriptions in this file disagree.

SSE subscribers are checked for table-level `select` permission and have denied columns stripped from each event, but the row-level `filter` predicates and the resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) are a property of the SQL query path and are **not** applied to the live event stream. If a role must never observe another tenant's rows in real time, don't grant it stream access to a shared table — scope the data at the table level.
:::caution[Live streams enforce column and row policy, but not resource limits]
SSE subscribers are checked for table-level `select` permission, have denied columns stripped from each event, and — like the query path — receive only the rows their role's row-level `filter` predicates admit, evaluated per subscriber against their JWT claims. Equality and set predicates (`_eq`, `_neq`, `_in`) — the usual tenant/user scoping — are enforced exactly: numeric ties resolve at full precision (an ID beyond `float64`'s 2^53 never falsely matches a neighbor) and an unparseable or `NaN` operand withholds the row. One representation caveat: a `Bool` (or `DateTime`) value compares by its canonical text form, without ClickHouse's cross-representation coercion — write the filter value the way your events carry it (`true`, not `1`). Ordering predicates (`_gt`, `_lt`) are best-effort: the stream compares numeric columns numerically (matching ClickHouse) but lacks ClickHouse's full per-type coercion, so an ordering filter on an exotic type (a `Decimal`/`Int128` beyond `float64` precision, or a `DateTime` ingested as a Unix number) can admit or withhold a row the query path wouldn't. The resource limits (`max_rows`, `max_execution_time`, `max_rows_to_read`, `max_memory_usage`) remain a property of the SQL query path and are **not** applied to the live event stream; if those caps are part of a role's isolation story, don't rely on them over the stream.
:::

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caution doesn't cover the fail-closed case a policy author is most likely to hit. rowfilter.go:56-58 withholds the row whenever the filter's column is absent from the event payload, and the stream evaluates against the ingested JSON rather than the stored row. So a filter keyed on a column clients don't send — or on a ClickHouse DEFAULT/MATERIALIZED/ALIAS column, which never appears in an SSE payload at all — silently withholds every event for that subscriber while the query path returns those rows normally.

Comment thread internal/stream/hub.go
numericResolved = true
}
for _, sub := range rb.bucket.Snapshot() {
subPerms := policy.Evaluate(p, rb.role, evt.TableName, "select", sub.claims)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed as #435 — not asking for it in this PR, but flagging it here so the cost is recorded rather than silently absorbed.

Nothing in this Evaluate call depends on the event. role and table are fixed for the connection (one subscriber = one topic = one table), claims are fixed at connect, and p changes only on policy reload — yet it runs once per event per subscriber. A subscriber connected across 10k events recomputes an identical ResolvedPermissions 10k times, each one paying a resolveTemplate regexp pass per filter value plus a predicatesToSQL render whose WhereClause/WhereParams the stream never reads.

Worth being explicit in the PR description or CHANGELOG that this trades some of the #294/#353 per-role fan-out gain for the RLS fix — right now the tradeoff isn't recorded anywhere, and af7afb9/8987480 (which had solved it) were reverted in 2379bc4 with an empty commit body.

Comment thread internal/stream/hub.go
for _, sub := range rb.bucket.Snapshot() {
subPerms := policy.Evaluate(p, rb.role, evt.TableName, "select", sub.claims)
if !subPerms.RowVisible(evt.Data, numericCols) {
continue // this row is filtered out for this subscriber

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A row withheld here is invisible to operators. Metrics exposes only ConnOpened / ConnClosed / FrameSent / FrameDropped (internal/stream/metrics.go), and FrameDropped means a full queue — an RLS withhold isn't counted anywhere.

That matters more than it would for an ordinary filter, because this path fails closed silently in several distinct ways: a filter column absent from the event payload (rowfilter.go:56), a non-scalar value, an unparseable or NaN operand, and — today — any table the registry has no schema for. All of them produce the same outcome: the subscriber quietly receives nothing.

An operator debugging "this subscriber isn't getting events" has no way to separate no matching rows from every row withheld by a misconfigured filter, and no signal at all when a policy edit starts withholding everything for a role. A counter here (ideally labelled by table/role) would make a fail-closed security feature observable.

Comment thread internal/stream/hub.go
}
if perms.HasRowFilter() {
subPerms := policy.Evaluate(p, role, evt.TableName, "select", claims)
if !subPerms.RowVisible(evt.Data, h.numericCols(evt.TableName)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h.numericCols(evt.TableName) runs once per replayed event. replayFromNATS loops (internal/api/stream.go:168), so a gap-fill of N events does N registry lookups plus N make(map[string]bool, len(schema.Columns)) allocations, each walking the full column list.

Broadcast hoists exactly this behind numericResolved (lines 164-167) and reuses it across every role and subscriber for the event. Replay doesn't, even though a Last-Event-ID reconnect after an outage is precisely the case where N gets large. Low stakes — replay is one-time and bounded — but the pattern is already sitting next door in the same file.

Comment thread internal/stream/hub.go
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc comment is updated, but the AGENTS.md invariant index isn't — and per AGENTS.md §Documentation Sync an architecture change maps to both architecture.md and AGENTS.md. (Commenting here because AGENTS.md isn't in this diff, so it can't be line-anchored.)

AGENTS.md:44 still describes the package as "Broadcast projects + serializes each event once per role, the #294 delivery hot path" — now true only for roles without a filter.

AGENTS.md:61 (Key Design Decision #12) closes with "Structured and live-stream (stream.filterColumns) reads share the one per-column decision policy.IsColumnAllowed, so column visibility can't drift." This PR builds the row-level twin of exactly that — one resolvePredicates call feeding both predicatesToSQL and RowVisible — and it isn't recorded anywhere in the index. That index is what the next agent touching internal/stream or internal/policy reads to learn what has to stay true, and "row visibility can't drift either" is precisely the kind of invariant it exists to hold.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BenchmarkBroadcast_RowFilteredFanout existed at 8987480 and was removed by 2379bc4 along with the optimization it measured. HEAD has no benchmarks in this package at all.

This connects to how the performance thread on hub.go:178 was closed. The reply there was: "we'd rather add a benchmark and optimize if profiling on a real filtered-high-fanout topic shows it matters, than add the complexity speculatively" — and CodeRabbit recorded a persistent learning from that exchange, so the flag is unlikely to resurface on its own in future reviews.

The branch's final state has neither the optimization nor the benchmark, so the condition that thread was resolved on can't actually be evaluated by anyone. Restoring just the benchmark — without the compiled-filter machinery that was reverted — would settle the question on data and guard the path against future regressions. See #435.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api HTTP handlers, routing, middleware area/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release area/policy Access control policies (Hasura-style) area/query Structured query AST, SQL builder area/sdk TypeScript SDK (clients/ts/) area/streaming SSE / live-query delivery path (/v1/stream) dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

security(streaming): SSE applies the column allowlist but not the policy row-filter — query/stream RLS drift

2 participants