From cdbdf3dc2b9008c8fa62dcd366ba970d425e9108 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 6 Jul 2026 19:05:08 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20request-s?= =?UTF-8?q?coped=20storage=20in=20coord=20tenancy=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _coordinator_tenant_check and _coord_attachment_owner resolved storage from the global registry (get_workstream_row / for_request without a storage arg), which can evaluate the project-tenancy decision against a different or auto-initialised backend and fail OPEN on a missing project row. Use request.app.state.auth_storage explicitly, matching cluster_ws_detail and _resolve_coordinator_or_404; fail closed (404) when it is unset. - reject_unassignable_scopes now derives its allowed-scope error message from ASSIGNABLE_SCOPES so validation and the message can't drift. (cherry picked from commit a40ff249ec57c526a3ce9aae7b0fd531cd91cedf) --- tests/test_coordinator_endpoints.py | 5 ++++- turnstone/console/server.py | 19 +++++++++++++++---- turnstone/core/auth.py | 3 ++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/test_coordinator_endpoints.py b/tests/test_coordinator_endpoints.py index d91a3798..3bc840ee 100644 --- a/tests/test_coordinator_endpoints.py +++ b/tests/test_coordinator_endpoints.py @@ -96,7 +96,10 @@ def _coord_attach_owner(request, ws_id, mgr): ws = mgr.get(ws_id) if ws is None: return "", JSONResponse({"error": "coordinator not found"}, status_code=404) - visibility = WorkstreamProjectVisibility.for_request(request) + storage = getattr(request.app.state, "auth_storage", None) + if storage is None: + return "", JSONResponse({"error": "coordinator not found"}, status_code=404) + visibility = WorkstreamProjectVisibility.for_request(request, storage=storage) if not visibility.ws_visible(getattr(ws, "project_id", "") or "", ws_owner=ws.user_id or ""): return "", JSONResponse({"error": "coordinator not found"}, status_code=404) return ws.user_id or auth_user_id(request), None diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 8b74073f..3cca070f 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -3466,21 +3466,27 @@ def _coordinator_tenant_check(request: Request, ws_id: str, mgr: Any) -> JSONRes previously got from the ``tenant_check is None`` manager-lookup guard. """ from turnstone.core.auth import WorkstreamProjectVisibility - from turnstone.core.memory import get_workstream_row miss = JSONResponse({"error": "coordinator not found"}, status_code=404) + # Use the request's configured storage (like cluster_ws_detail / + # _resolve_coordinator_or_404) — NOT the global registry, which can + # resolve a different/auto-init'd backend and evaluate the tenancy + # decision against the wrong DB (fail-open on a missing project row). + storage = getattr(request.app.state, "auth_storage", None) + if storage is None: + return miss ws = mgr.get(ws_id) if mgr is not None else None if ws is not None: # coord_mgr only holds coordinators, so kind is implied. project_id = getattr(ws, "project_id", "") or "" owner = ws.user_id or "" else: - row = get_workstream_row(ws_id) + row = storage.get_workstream(ws_id) if row is None or row.get("kind") != WorkstreamKind.COORDINATOR: return miss project_id = row.get("project_id") or "" owner = row.get("user_id") or "" - visibility = WorkstreamProjectVisibility.for_request(request) + visibility = WorkstreamProjectVisibility.for_request(request, storage=storage) if not visibility.ws_visible(project_id, ws_owner=owner): return miss return None @@ -13624,7 +13630,12 @@ def create_app( # Private-project tenancy: a coordinator attached to a private project # serves attachments only to its members — admin.coordinator gates the # surface, not the tenancy. 404-mask non-members like the other verbs. - visibility = WorkstreamProjectVisibility.for_request(request) + # Use the request's configured storage (not the global registry) so the + # visibility decision can't be evaluated against the wrong DB. + storage = getattr(request.app.state, "auth_storage", None) + if storage is None: + return "", JSONResponse({"error": "coordinator not found"}, status_code=404) + visibility = WorkstreamProjectVisibility.for_request(request, storage=storage) if not visibility.ws_visible( getattr(ws, "project_id", "") or "", ws_owner=ws.user_id or "" ): diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py index 9fee0e24..1a6f2507 100644 --- a/turnstone/core/auth.py +++ b/turnstone/core/auth.py @@ -92,7 +92,8 @@ def reject_unassignable_scopes(scopes_csv: str) -> str | None: """ requested = {s.strip() for s in scopes_csv.split(",") if s.strip()} if not requested or not requested.issubset(ASSIGNABLE_SCOPES): - return "Invalid scopes (allowed: read, write, approve)" + allowed = ", ".join(sorted(ASSIGNABLE_SCOPES)) + return f"Invalid scopes (allowed: {allowed})" return None