fix: use envelope workspace for mux authorisation, not inner request body (#1000)

The mux was extracting the authorisation resource workspace from the
inner request body via registry extractors. But workspace-scoped
services (config, flow, librarian, etc.) receive workspace from the
queue identity, not the message body — the inner workspace field is
a dead field that no service handler reads.

This caused access-denied errors when the inner body's workspace
(e.g. CLI default "default") disagreed with the caller's assigned
workspace, even though the envelope workspace was correct.

Fix: resolve workspace from the envelope only. Split the non-flow
authorisation path by resource level — WORKSPACE ops use the envelope
workspace directly; SYSTEM ops (IAM) still use registry extractors
since they legitimately read operation-specific body fields.
This commit is contained in:
cybermaggedon 2026-06-25 13:44:57 +01:00 committed by GitHub
parent a3df4f62bb
commit 16f8cfd972
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -5,6 +5,7 @@ import uuid
import logging
from ..capabilities import PUBLIC, AUTHENTICATED
from ..registry import ResourceLevel
# Module logger
logger = logging.getLogger(__name__)
@ -159,12 +160,14 @@ class Mux:
return
# Resolve workspace (default-fill from the caller's
# bound workspace). Workspace resolution applies to all
# operations regardless of capability level.
# bound workspace). The envelope workspace is the
# single canonical workspace for routing AND
# authorisation. The inner request body's workspace
# field is not consulted — workspace-scoped services
# receive workspace from the queue identity, not the
# message body.
try:
await enforce_workspace(data, self.identity, self.auth)
if isinstance(inner, dict):
await enforce_workspace(inner, self.identity, self.auth)
# Authorisation: capability sentinels short-circuit
# the regime call; capability strings go through
@ -176,11 +179,18 @@ class Mux:
"flow": data.get("flow", ""),
}
parameters = {}
elif op.resource_level == ResourceLevel.WORKSPACE:
# Workspace-scoped services (config, flow,
# librarian, etc.) — workspace comes from the
# envelope, same as flow-level services.
resource = {
"workspace": data.get("workspace", ""),
}
parameters = {}
else:
# Build a minimal RequestContext so the matched
# operation's own extractors decide resource
# and parameters — same path the HTTP
# endpoints take.
# System-level services (IAM) — resource is
# {} and parameters come from the inner body
# (e.g. user.workspace, workspace_record.id).
from ..registry import RequestContext
ctx = RequestContext(
body=inner if isinstance(inner, dict) else {},