Skip to content

feat(websocket): realtime transport backend — entity-change nudges + channel tokens - #43431

Merged
villebro merged 5 commits into
apache:gaq-to-gtffrom
villebro:villebro/gtf-websocket
Aug 23, 2026
Merged

feat(websocket): realtime transport backend — entity-change nudges + channel tokens#43431
villebro merged 5 commits into
apache:gaq-to-gtffrom
villebro:villebro/gtf-websocket

Conversation

@villebro

Copy link
Copy Markdown
Member

SUMMARY

Step 6 of the GAQ→GTF epic (#43407): realtime websocket transport — backend foundation. Step 4 removed the GAQ firehose + async-token JWT that the old superset-websocket server relied on (async chart-data went polling-only). This PR lays the backend foundation for a generalized, GTF-native realtime push transport. The Node server rewrite and the frontend socket client follow in subsequent slices (see Remaining below).

The transport has two tiers, each with the authorization that fits it:

  • Public per-entity-type pub/sub (lossy, list-view activity). GTF publishes an opaque change nudge — {entity_type, id}, no status or payload — to a per-type channel (entity-changes:task, later :dashboard, :chart, :dataset, …). A list view subscribes only to its type and re-fetches the actual (authz-scoped) data through the authorized REST API on a nudge. The contract is general across all entity types; id existence is intentionally public, so this channel never carries authz-sensitive data.
  • Per-principal channel (dashboard chart-data path). A connection is authenticated by a JWT cookie and bound to a deterministic per-principal channel (user:<id> / guest:<hmac>); the server delivers a channel's events only to that principal's sockets — the same isolation master's async-events transport had, so this path can carry sensitive completion detail. (The publish side + delivery are wired in the Node/frontend slices.)

What's in this PR (backend foundation)

  • TaskManager.publish_entity_change(task_uuid) — best-effort pub/sub of the opaque nudge to entity-changes:task on task completion (emitted alongside the existing guaranteed completion signal; strictly best-effort, never disrupts it — the interval poll remains the backstop).
  • superset/websocket/channel.py — a feature-agnostic channel-token service (replacing the GAQ async-token handshake removed in step 4): get_channel_id() (per-principal, reusing the guest identity from superset.tasks.guest), mint_channel_token(), and register_ws_channel_cookie() (an after_request httponly JWT cookie the websocket server verifies). Deterministic per-principal channels (vs the legacy per-session-random) let the task layer publish a user's events to their channel.
  • New WEBSOCKET_* config (WEBSOCKET_ENABLED, WEBSOCKET_URL, WEBSOCKET_JWT_SECRET, WEBSOCKET_JWT_COOKIE_NAME/_SECURE/_SAMESITE/_DOMAIN/_EXPIRATION_SECONDS), a gated configure_websocket() in initialization, and WEBSOCKET_ENABLED/WEBSOCKET_URL exposed to the frontend bootstrap.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A — backend only; no user-visible change until the Node/frontend slices land.

TESTING INSTRUCTIONS

Automated: pytest tests/unit_tests/tasks/test_manager.py tests/unit_tests/websocket/. With DISTRIBUTED_COORDINATION_CONFIG set, completing a GTF task publishes {entity_type: "task", id} on entity-changes:task (best-effort). With WEBSOCKET_ENABLED=True + a WEBSOCKET_JWT_SECRET, authenticated responses carry the superset-ws-token cookie whose channel claim is user:<id> (or guest:<hmac>).

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration
  • Introduces new feature or API (realtime entity-change pub/sub; websocket channel-token service + WEBSOCKET_* config)
  • Removes existing feature or API

Remaining step-6 slices (this PR or immediate follow-ups): per-principal chart-data publish (task completion → submitter's channel); rewrite superset-websocket/src/index.ts (subscribe the public entity-changes:* broadcast + route per-principal channel events by the JWT channel claim; drop the async-events-full/result_url firehose model); frontend socket client in asyncEvent.ts (accelerate chart-data completion + trigger list re-fetches; interval poll stays the fallback); config/docker/docs. Shipping the websocket server on the official image is step 8 (#43407 tracker).

Targets the gaq-to-gtf feature branch (part of #43407), not master.

Backend foundation for the realtime websocket transport (step 6): on each task
completion, TaskManager.publish_entity_change emits a best-effort pub/sub nudge
to a public, per-entity-type channel (entity-changes:task) carrying only
non-sensitive fields ({entity_type, id, status}) — no payload. It's published
once to the type channel (not fanned per subscriber), so a widely-shared entity
is one message. A browser transport forwards it; clients re-fetch any
authz-sensitive data through the authorized REST API (status_changes / chart
re-request) where TaskFilter/RLS apply. Strictly best-effort — never disrupts
completion signalling; the interval poll is the backstop.
Per review: the public per-entity-type pub/sub must disclose no sensitive info
and stay general across all entity types (dashboards, charts, datasets, RLS,
alerts) — status is task-specific and does not generalize. Drop status from the
nudge; it now carries only {entity_type, id}. Per-type channels stay
(entity-changes:task, later :dashboard/:chart/...) so a list view subscribes
only to its type. Status/sensitive detail moves to the per-principal tier or the
authorized re-fetch.
…ion auth)

Reinstate websocket connection auth as a feature-agnostic service (replacing the
GAQ async-token handshake removed in step 4). superset/websocket/channel.py
derives a deterministic per-principal channel (user:<id> / guest:<hmac>, reusing
the guest identity from superset.tasks.guest) and mints a JWT cookie the
superset-websocket server verifies to bind a socket to its channel. Deterministic
per-principal channels (vs the legacy per-session-random) let the task layer
publish a user's events to their channel. New WEBSOCKET_* config (ENABLED, URL,
JWT_SECRET/cookie), gated cookie registration in init, exposed to bootstrap conf.
@dosubot dosubot Bot added the change:backend Requires changing the backend label Aug 23, 2026
@bito-code-review

bito-code-review Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped - Branch Excluded

Bito didn't auto-review because the source or target branch is excluded from automatic reviews.
No action is needed if you didn't intend for the agent to review it. Otherwise, to manually trigger a review, type /review in a comment and save.
You can change the branch exclusion settings here, or contact your Bito workspace admin at evan@preset.io.

@villebro

Copy link
Copy Markdown
Member Author

/review

@netlify

netlify Bot commented Aug 23, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 7244b7c
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a8b15ec5bda78000856f2f9
😎 Deploy Preview https://deploy-preview-43431--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

The tests/unit_tests/websocket/__init__.py was created empty (touch); RAT
requires the Apache header.
Comment thread superset/constants.py

CHANGE_ME_SECRET_KEY = "CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET" # noqa: S105
CHANGE_ME_GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me" # noqa: S105
CHANGE_ME_WEBSOCKET_JWT_SECRET = "test-ws-secret-change-me" # noqa: S105

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: This introduces a known, fixed signing key for websocket authentication. Any deployment that enables the websocket transport without explicitly overriding WEBSOCKET_JWT_SECRET allows anyone who knows this public default to forge a JWT for arbitrary user:<id> channels and receive sensitive per-principal events. Do not use a known fallback secret for an authentication token; fail startup when a strong deployment-specific key is not configured. [security]

Severity Level: Critical 🚨
- ❌ Forged sockets can subscribe to arbitrary user channels.
- ❌ Sensitive per-principal chart events may be exposed.
- ⚠️ Production configuration permits startup with the known fallback.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/constants.py
**Line:** 32:32
**Comment:**
	*Security: This introduces a known, fixed signing key for websocket authentication. Any deployment that enables the websocket transport without explicitly overriding `WEBSOCKET_JWT_SECRET` allows anyone who knows this public default to forge a JWT for arbitrary `user:<id>` channels and receive sensitive per-principal events. Do not use a known fallback secret for an authentication token; fail startup when a strong deployment-specific key is not configured.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 100e208 via a startup guard, check_websocket_secret() (initialization/init.py, called in the check sequence): when WEBSOCKET_ENABLED is on and WEBSOCKET_JWT_SECRET is the placeholder or shorter than 32 bytes, it warns and sys.exit(1) outside debug/testing — so an enabled production deployment cannot run with the known default. This mirrors the established check_guest_token_secret / check_secret_key convention (a CHANGE_ME_* placeholder plus a startup check), rather than introducing a different pattern. Covered by check_websocket_secret_test.py.

Comment thread superset/websocket/channel.py Outdated
Comment on lines +92 to +93
if request.cookies.get(cookie_name):
return response

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: The cookie is accepted solely because a value with this name exists; its JWT is never checked against the current principal or expiration. After logout/login, the browser can keep the previous user's token, so the websocket server binds the new session to a stale channel and may expose the previous user's per-principal events to the newly logged-in user. Decode and validate the existing token's channel against channel_id, or replace/delete it whenever the principal changes. [security]

Severity Level: Critical 🚨
- ❌ Cross-user websocket sessions can receive private events.
- ❌ Dashboard chart-data completion details may leak.
- ⚠️ Logout/login does not rotate the channel cookie.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/websocket/channel.py
**Line:** 92:93
**Comment:**
	*Security: The cookie is accepted solely because a value with this name exists; its JWT is never checked against the current principal or expiration. After logout/login, the browser can keep the previous user's token, so the websocket server binds the new session to a stale channel and may expose the previous user's per-principal events to the newly logged-in user. Decode and validate the existing token's channel against `channel_id`, or replace/delete it whenever the principal changes.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 100e208. register_ws_channel_cookie no longer trusts a cookie just because it exists: _cookie_channel() decodes/verifies the existing token and the cookie is re-minted whenever its channel claim ≠ the current principal (missing, invalid, expired, or a different user after logout→login), and delete_cookie is called when there is no principal (anonymous / just logged out — the after_request runs after the session is cleared, so get_channel_id() returns None). Covered by test_cookie_reminted_when_principal_changes and test_cookie_cleared_for_anonymous.

Comment thread superset/config.py
# sockets. Set a strong random WEBSOCKET_JWT_SECRET (>= 32 bytes) in production.
WEBSOCKET_ENABLED = False
WEBSOCKET_URL = "ws://127.0.0.1:8080/"
WEBSOCKET_JWT_SECRET = CHANGE_ME_WEBSOCKET_JWT_SECRET

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: When WEBSOCKET_ENABLED is set to True, this default remains the publicly known CHANGE_ME_WEBSOCKET_JWT_SECRET, and the initialization path does not reject it. Attackers who know this value can forge channel JWTs and subscribe sockets to another principal's private channel. Add startup validation that refuses enabled deployments using the placeholder, or require an explicitly configured secret. [security]

Severity Level: Critical 🚨
- ❌ Forged JWTs can access another user's private channel.
- ❌ Dashboard chart-data events can become cross-user readable.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/config.py
**Line:** 2949:2949
**Comment:**
	*Security: When `WEBSOCKET_ENABLED` is set to `True`, this default remains the publicly known `CHANGE_ME_WEBSOCKET_JWT_SECRET`, and the initialization path does not reject it. Attackers who know this value can forge channel JWTs and subscribe sockets to another principal's private channel. Add startup validation that refuses enabled deployments using the placeholder, or require an explicitly configured secret.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 100e208 via a startup guard, check_websocket_secret() (initialization/init.py, called in the check sequence): when WEBSOCKET_ENABLED is on and WEBSOCKET_JWT_SECRET is the placeholder or shorter than 32 bytes, it warns and sys.exit(1) outside debug/testing — so an enabled production deployment cannot run with the known default. This mirrors the established check_guest_token_secret / check_secret_key convention (a CHANGE_ME_* placeholder plus a startup check), rather than introducing a different pattern. Covered by check_websocket_secret_test.py.

self.configure_ssh_manager()
self.configure_stats_manager()
self.configure_task_manager()
self.configure_websocket()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: The websocket cookie hook is now enabled for every configured websocket deployment, but the hook retains an existing token without checking whether its channel still matches the current principal. After a user logs out and another user logs in in the same browser, the old bearer cookie remains valid for up to the token expiration and the new websocket connection can continue using the previous user's channel, causing missed events or exposure of that user's private events. Clear or re-mint the cookie when the authenticated principal changes, and invalidate it on logout. [security]

Severity Level: Critical 🚨
- ❌ User switching can expose prior user websocket events.
- ❌ Dashboard chart completion details may reach the wrong principal.
- ⚠️ Impact requires websocket transport deployment and logout/login reuse.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/initialization/__init__.py
**Line:** 1049:1049
**Comment:**
	*Security: The websocket cookie hook is now enabled for every configured websocket deployment, but the hook retains an existing token without checking whether its channel still matches the current principal. After a user logs out and another user logs in in the same browser, the old bearer cookie remains valid for up to the token expiration and the new websocket connection can continue using the previous user's channel, causing missed events or exposure of that user's private events. Clear or re-mint the cookie when the authenticated principal changes, and invalidate it on logout.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 100e208. register_ws_channel_cookie no longer trusts a cookie just because it exists: _cookie_channel() decodes/verifies the existing token and the cookie is re-minted whenever its channel claim ≠ the current principal (missing, invalid, expired, or a different user after logout→login), and delete_cookie is called when there is no principal (anonymous / just logged out — the after_request runs after the session is cleared, so get_channel_id() returns None). Covered by test_cookie_reminted_when_principal_changes and test_cookie_cleared_for_anonymous.

…ak secret

Address two Critical review findings on the channel-token service:
- The cookie hook only minted when absent, so after a logout/login in the same
  browser the previous user's cookie (channel user:<oldid>) persisted and could
  bind the new session to the prior user's channel. Now re-mint whenever the
  cookie's channel claim doesn't match the current principal (missing, invalid/
  expired, or a different user), and delete it when there is no principal
  (anonymous / logged out).
- WEBSOCKET_ENABLED with the default/placeholder or <32-byte WEBSOCKET_JWT_SECRET
  let an attacker forge channel tokens. Add check_websocket_secret startup guard
  (mirrors check_guest_token_secret): warn always, refuse to start outside
  debug/testing. Tests for cookie rotation/clear + the secret guard.
@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.36145% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.83%. Comparing base (077c6d3) to head (100e208).
⚠️ Report is 1 commits behind head on gaq-to-gtf.

Files with missing lines Patch % Lines
superset/websocket/channel.py 88.37% 3 Missing and 2 partials ⚠️
superset/initialization/__init__.py 82.35% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           gaq-to-gtf   #43431   +/-   ##
===========================================
  Coverage       78.83%   78.83%           
===========================================
  Files            2879     2880    +1     
  Lines          164342   164407   +65     
  Branches        37985    37994    +9     
===========================================
+ Hits           129561   129617   +56     
- Misses          32341    32347    +6     
- Partials         2440     2443    +3     
Flag Coverage Δ
hive 38.15% <20.48%> (-0.03%) ⬇️
mysql 57.70% <20.48%> (-0.05%) ⬇️
postgres 57.73% <20.48%> (-0.05%) ⬇️
presto 40.08% <20.48%> (-0.03%) ⬇️
python 83.51% <90.36%> (+<0.01%) ⬆️
sqlite 57.43% <20.48%> (-0.05%) ⬇️
unit 73.59% <90.36%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@villebro
villebro merged commit 5719f04 into apache:gaq-to-gtf Aug 23, 2026
65 checks passed
@villebro
villebro deleted the villebro/gtf-websocket branch August 23, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant