fix: skip authorise() for AUTHENTICATED/PUBLIC sentinels in WebSocket mux

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.
This commit is contained in:
Cyber MacGeddon 2026-06-03 09:43:54 +01:00
parent 60f861bac4
commit ac9968b1e0

View file

@ -4,6 +4,8 @@ import queue
import uuid import uuid
import logging import logging
from ..capabilities import PUBLIC, AUTHENTICATED
# Module logger # Module logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -156,37 +158,41 @@ class Mux:
}) })
return return
# Resolve workspace first (default-fill from the caller's # Resolve workspace (default-fill from the caller's
# bound workspace), then ask the regime to authorise the # bound workspace). Workspace resolution applies to all
# service-level capability against the matched # operations regardless of capability level.
# operation's resource shape.
try: try:
await enforce_workspace(data, self.identity, self.auth) await enforce_workspace(data, self.identity, self.auth)
if isinstance(inner, dict): if isinstance(inner, dict):
await enforce_workspace(inner, self.identity, self.auth) await enforce_workspace(inner, self.identity, self.auth)
if data.get("flow"): # Authorisation: capability sentinels short-circuit
resource = { # the regime call; capability strings go through
"workspace": data.get("workspace", ""), # authorise().
"flow": data.get("flow", ""), if op.capability not in (PUBLIC, AUTHENTICATED):
} if data.get("flow"):
parameters = {} resource = {
else: "workspace": data.get("workspace", ""),
# Build a minimal RequestContext so the matched "flow": data.get("flow", ""),
# operation's own extractors decide resource and }
# parameters — same path the HTTP endpoints take. parameters = {}
from ..registry import RequestContext else:
ctx = RequestContext( # Build a minimal RequestContext so the matched
body=inner if isinstance(inner, dict) else {}, # operation's own extractors decide resource
match_info={}, # and parameters — same path the HTTP
identity=self.identity, # endpoints take.
) from ..registry import RequestContext
resource = op.extract_resource(ctx) ctx = RequestContext(
parameters = op.extract_parameters(ctx) 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( await self.auth.authorise(
self.identity, op.capability, resource, parameters, self.identity, op.capability, resource, parameters,
) )
except _web.HTTPNotFound: except _web.HTTPNotFound:
await self.ws.send_json({ await self.ws.send_json({
"id": request_id, "id": request_id,