villebro commented on code in PR #45404:
URL: https://github.com/apache/superset/pull/45404#discussion_r4427742926


##########
superset/websocket/api.py:
##########
@@ -0,0 +1,78 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""REST API issuing connection tickets for the realtime websocket transport."""
+
+from __future__ import annotations
+
+from flask import current_app, Response
+from flask_appbuilder.api import expose, protect, safe
+from flask_appbuilder.security.decorators import permission_name
+
+from superset.extensions import event_logger
+from superset.views.base_api import BaseSupersetApi, statsd_metrics
+from superset.websocket.channel import get_realtime_principal, 
mint_channel_token
+from superset.websocket.permissions import REALTIME_NOTIFICATION_RESOURCE
+
+
+class RealtimeRestApi(BaseSupersetApi):
+    """Issues short-lived tickets the browser presents to the websocket 
server."""
+
+    resource_name = "realtime"
+    class_permission_name = REALTIME_NOTIFICATION_RESOURCE
+    allow_browser_login = True
+    openapi_spec_tag = "Realtime"
+
+    @expose("/token/", methods=("GET",))
+    @event_logger.log_this
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("read")
+    def token(self) -> Response:
+        """Get a websocket connection ticket for the current principal.
+        ---
+        get:
+          summary: Get a websocket connection ticket
+          description: >-
+            Returns a short-lived JWT the client sends as the first frame of a
+            websocket connection, and again on the open socket to refresh it
+            before it expires.
+          responses:
+            200:
+              description: A connection ticket and its lifetime in seconds
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      token:
+                        type: string
+                      expires_in:
+                        type: integer
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+        """
+        principal = get_realtime_principal()
+        if principal is None:
+            return self.response_401()
+        return self.response(

Review Comment:
   Valid. The ticket response now sets `Cache-Control: no-store, private`, so a 
shared cache can't store one guest's ticket and serve it to another. The 
existing token-endpoint test asserts the header. Fixed in 2d0d1a8d26.



##########
superset-websocket/spec/auth.test.ts:
##########
@@ -0,0 +1,354 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * End-to-end tests of the in-band authentication state machine over real
+ * WebSocket connections on a loopback port: pending_auth -> authenticated,
+ * in-band refresh, and the close codes that tell a client what to do next.
+ */
+import jwt from 'jsonwebtoken';
+import * as http from 'http';
+import { duplexPair, type Duplex } from 'stream';
+import { afterAll, beforeAll, beforeEach, expect, test, vi } from 'vitest';
+import { WebSocket } from 'ws';
+import config from '../config.test.json' with { type: 'json' };
+import * as server from '../src/index';
+
+vi.mock('ioredis', () => ({
+  Redis: vi.fn().mockImplementation(function () {
+    return { subscribe: vi.fn(), on: vi.fn() };
+  }),
+}));
+
+type Frame = { topic: string; payload: Record<string, unknown> };
+
+const signTicket = (
+  channel = 'user:5',
+  expiresInSeconds = 3600,
+  secret = config.jwtSecret,
+) =>
+  jwt.sign(
+    {
+      channel,
+      sub: channel.replace('user:', ''),
+      principal_type: 'user',
+      aud: 'superset-websocket',
+      iss: 'superset',
+      exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
+    },
+    secret,
+  );
+
+// The server under test is driven over in-memory duplex streams rather than a
+// TCP listener: the HTTP parser, the upgrade handshake and the WebSocket 
framing
+// are all real, only the transport is a pipe.
+let httpServer: http.Server;
+const clients: WebSocket[] = [];
+
+/** Give a stream the socket methods `http` and `ws` call on their transport. 
*/
+const asSocket = (stream: Duplex): Duplex =>
+  Object.assign(stream, {
+    setTimeout: () => stream,
+    setNoDelay: () => stream,
+    setKeepAlive: () => stream,
+    ref: () => stream,
+    unref: () => stream,
+    remoteAddress: '127.0.0.1',
+  });
+
+/** A client socket whose peer is the server under test. */
+const connectToServer = (): Duplex => {
+  const [clientSide, serverSide] = duplexPair();
+  httpServer.emit('connection', asSocket(serverSide));
+  return asSocket(clientSide);
+};
+
+const newClient = (
+  options: ConstructorParameters<typeof WebSocket>[2] = {},
+) => {
+  const ws = new WebSocket('ws://localhost/', {
+    createConnection: () => connectToServer() as never,
+    ...options,
+  });
+  clients.push(ws);
+  return ws;
+};
+
+beforeAll(() => {
+  httpServer = http.createServer();
+  httpServer.on('upgrade', server.httpUpgrade);
+});
+
+beforeEach(() => {
+  server.resetState();
+  clients.splice(0).forEach(client => client.terminate());

Review Comment:
   Valid. `beforeEach` now closes both ends of each previous connection and 
waits until `pendingConnectionCount()` is 0 before calling `resetState()`, so a 
late close can't decrement the freshly reset count. Terminating only the client 
doesn't reach the server over the in-memory duplex pipe, so the test also 
destroys the server-side streams. The suite passed 10 of 10 local runs. Fixed 
in 2d0d1a8d26.



##########
SECURITY.md:
##########
@@ -90,7 +90,7 @@ Asynchronous execution paths do not create a separate 
data-access capability. A
 
 Task metadata is itself a request-scoped resource. Non-admin users and 
embedded guests may read or cancel only tasks they are subscribed to or that 
otherwise represent work they are entitled to observe; Admin may observe and 
manage tasks as part of the trusted operational boundary. A bug that lets a 
principal create, read, join, cancel, or receive task state for work outside 
the role and capability matrix is in scope.
 
-Realtime transports, including WebSocket delivery backed by Redis or Valkey 
Pub/Sub, are notification mechanisms rather than authorization sources. 
WebSocket notification access is controlled by `can_read` on the `Realtime` 
resource. The broadcast scope is authenticated-global, not public: it reaches 
every authenticated realtime socket, and an anonymous request receives no 
realtime principal, no JWT cookie, and therefore no socket, so it never 
receives these messages (true anonymous/Public-role realtime is not offered and 
would require a separate, restricted model). Broadcast Pub/Sub messages, such 
as list-view entity-change events, must be context-free nudges; sensitive or 
authoritative state must not be published on the broadcast scope. Targeted 
Pub/Sub messages may carry task state only when the producer names routing keys 
derived from an authorized identity, such as a task subscriber's principal 
channel (or a per-tab channel derived from it); the producer validates every 
routing
  key against the task's own subscriber principals before publishing, and the 
websocket server forwards the payload only to sockets bound to those keys. Full 
data and result payloads must still be fetched through the normal protected 
REST API or cache-read path. Redis Streams used for task completion, 
dependency, and lock-release signalling are likewise coordination signals; the 
metastore or cache entry they wake a consumer to read remains the source of 
truth.
+Realtime transports, including WebSocket delivery backed by Redis or Valkey 
Pub/Sub, are notification mechanisms rather than authorization sources. 
WebSocket notification access is controlled by `can_read` on the `Realtime` 
resource. The broadcast scope is authenticated-global, not public: it reaches 
every authenticated realtime socket, and an anonymous request receives no 
realtime principal, no connection ticket, and therefore no socket, so it never 
receives these messages (true anonymous/Public-role realtime is not offered and 
would require a separate, restricted model). Broadcast Pub/Sub messages, such 
as list-view entity-change events, must be context-free nudges; sensitive or 
authoritative state must not be published on the broadcast scope. Targeted 
Pub/Sub messages may carry task state only when the producer names routing keys 
derived from an authorized identity, such as a task subscriber's principal 
channel (or a per-tab channel derived from it); the producer validates every 
 routing key against the task's own subscriber principals before publishing, 
and the websocket server forwards the payload only to sockets bound to those 
keys. Full data and result payloads must still be fetched through the normal 
protected REST API or cache-read path. Redis Streams used for task completion, 
dependency, and lock-release signalling are likewise coordination signals; the 
metastore or cache entry they wake a consumer to read remains the source of 
truth.
 
 The realtime notification permission is distinct from the permission to read 
the underlying object. It controls whether a principal receives push 
notifications, not whether they may read the object once they call the 
protected REST API. Existing websocket connections are authorized by the JWT 
accepted at upgrade time; permission revocation after token minting is bounded 
by `WEBSOCKET_JWT_EXPIRATION_SECONDS` plus the websocket server's socket-check 
interval. Redis Streams are internal server-to-server coordination primitives 
and should not be directly exposed as an end-user subscription surface.

Review Comment:
   Valid. SECURITY.md now says an open connection is authorized by the most 
recent ticket presented on it (the first `auth` frame, then each in-band 
refresh), and that revocation is bounded by the ticket lifetime plus the 
socket-check interval because the next refresh is refused. Fixed in 2d0d1a8d26.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to