From aa158e1ba3c886f6fed731da5bb44a498a2f806f Mon Sep 17 00:00:00 2001 From: cybermaggedon Date: Wed, 3 Jun 2026 09:45:53 +0100 Subject: [PATCH] fix: skip authorise() for AUTHENTICATED/PUBLIC sentinels in WebSocket mux (#972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mux unconditionally called auth.authorise() for every operation, passing capability sentinels like AUTHENTICATED ("__authenticated__") to the IAM regime. Since no role grants "__authenticated__", the regime denied the request — breaking whoami (and any future AUTHENTICATED-only operation) over the WebSocket path while the HTTP endpoints worked fine. Match the guard pattern used by iam_endpoint.py and registry_endpoint.py: only call authorise() for real capability strings, not sentinels. --- .../trustgraph/gateway/dispatch/mux.py | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/trustgraph-flow/trustgraph/gateway/dispatch/mux.py b/trustgraph-flow/trustgraph/gateway/dispatch/mux.py index 73bbb1f3..9b119f8e 100644 --- a/trustgraph-flow/trustgraph/gateway/dispatch/mux.py +++ b/trustgraph-flow/trustgraph/gateway/dispatch/mux.py @@ -4,6 +4,8 @@ import queue import uuid import logging +from ..capabilities import PUBLIC, AUTHENTICATED + # Module logger logger = logging.getLogger(__name__) @@ -156,37 +158,41 @@ class Mux: }) return - # Resolve workspace first (default-fill from the caller's - # bound workspace), then ask the regime to authorise the - # service-level capability against the matched - # operation's resource shape. + # Resolve workspace (default-fill from the caller's + # bound workspace). Workspace resolution applies to all + # operations regardless of capability level. try: await enforce_workspace(data, self.identity, self.auth) if isinstance(inner, dict): await enforce_workspace(inner, self.identity, self.auth) - if data.get("flow"): - resource = { - "workspace": data.get("workspace", ""), - "flow": data.get("flow", ""), - } - parameters = {} - else: - # Build a minimal RequestContext so the matched - # operation's own extractors decide resource and - # parameters — same path the HTTP endpoints take. - from ..registry import RequestContext - ctx = RequestContext( - body=inner if isinstance(inner, dict) else {}, - match_info={}, - identity=self.identity, - ) - resource = op.extract_resource(ctx) - parameters = op.extract_parameters(ctx) + # Authorisation: capability sentinels short-circuit + # the regime call; capability strings go through + # authorise(). + if op.capability not in (PUBLIC, AUTHENTICATED): + if data.get("flow"): + resource = { + "workspace": data.get("workspace", ""), + "flow": data.get("flow", ""), + } + parameters = {} + else: + # Build a minimal RequestContext so the matched + # operation's own extractors decide resource + # and parameters — same path the HTTP + # endpoints take. + from ..registry import RequestContext + ctx = RequestContext( + body=inner if isinstance(inner, dict) else {}, + match_info={}, + identity=self.identity, + ) + resource = op.extract_resource(ctx) + parameters = op.extract_parameters(ctx) - await self.auth.authorise( - self.identity, op.capability, resource, parameters, - ) + await self.auth.authorise( + self.identity, op.capability, resource, parameters, + ) except _web.HTTPNotFound: await self.ws.send_json({ "id": request_id,