Files
turnstone/tests/test_console_routing_proxy.py
T
Patrick Buckley d068366a61 rbac: builtin-role override editor + tighten under-enforced perm gates (#585)
* feat(rbac): editable builtin role permissions via overlay layer

Adds a ``role_permission_overrides`` table that stores per-(role_id,
permission) grant/revoke deltas, applied on top of the immutable
``roles.permissions`` baseline at permission-load time. Builtin roles
(``builtin-admin/operator/viewer``) become customizable through the
admin Roles UI without losing the "reset to default" guarantee — every
override is auditable and reversible.

Motivating case: ``model.skills.write`` is deliberately default-ungranted
on every role so operators must consciously opt in before a coordinator
session can mutate the skill catalog. Until now there was no UX path to
do that opt-in — the only options were dropping into SQL or running a
fresh migration. The overrides editor closes that gap.

Backend
- Migration 057 + storage methods on both sqlite + postgresql backends
- ``get_user_permissions`` merges baseline ∪ grants − revokes for builtin
  rows; custom rows pass through unchanged
- ``GET /v1/api/admin/roles/{id}/effective`` for inspect
- ``PUT /v1/api/admin/roles/{id}/overrides`` for write — admin.roles gated,
  audited, validates against ``_VALID_PERMISSIONS``, refuses non-builtin
  targets, strips no-op grants/revokes before persisting
- Lockout guard: cannot revoke ``admin.roles`` if doing so would leave
  zero users with the permission (returns 409)
- ``coordinator.trust.send`` added to ``_VALID_PERMISSIONS`` — was
  seeded into builtin-admin by migration 042 but never registered with
  the validator, so the very first round-trip through the editor 400'd
  on it. Drift-detection test guards future migrations from recreating
  the same gap

Frontend
- Roles tab redesign: chevron + permission-count chip replace the
  "..." truncation; expand-on-click drawer groups perms by namespace
  with baseline / grant (green +) / revoke (red −) chip variants
- Edit modal opens for builtin rows ("Customize Built-in Role" title);
  toggles show baseline-default vs override state; submit diffs against
  the rendered toggle universe (not raw baseline) so future taxonomy
  drift can't silently strip unknown perms
- "Modified +N/-N" pill on rows with active overrides; "Reset to default"
  drawer action clears the override set
- ``_PERMISSION_SECTIONS`` brought up to date with all currently-seeded
  perms (admin.coordinator, admin.cluster.inspect, admin.models,
  admin.nodes, admin.prompt_policies, conversation.modify,
  coordinator.trust.send were missing)

Tests
- 7 storage tests covering set/list/clear/effective + overlay merge into
  ``get_user_permissions`` for both builtin and custom roles
- 11 endpoint tests covering effective/overrides happy paths, validation,
  lockout guard, builtin-only restriction, no-op normalization, list
  enrichment

* feat(rbac): enforce workstreams.{create,close} + tools.approve gates

These three permissions were declared in ``_VALID_PERMISSIONS``, seeded
into ``builtin-operator``'s baseline by migration 008/017, surfaced in
the admin Roles UI as toggles, and documented in ``bootstrap.py`` as
the operator role's capabilities — and never enforced anywhere. The
audit that ran out of the overlay PR found zero ``require_permission``
sites for any of them; any authenticated user could create workstreams,
close any workstream, or approve any pending tool regardless of role.

Behaviour change for callers without the perms:

- ``POST /v1/api/workstreams/new`` (node + console proxy variants)
  now 403 without ``workstreams.create``
- ``POST /v1/api/workstreams/{ws_id}/close`` (and ``/route/`` proxy)
  now 403 without ``workstreams.close``
- ``POST /v1/api/workstreams/{ws_id}/approve`` (and ``/route/`` proxy)
  now 403 without ``tools.approve``

The OR-fallback to ``admin.coordinator`` keeps coord sessions spawning
interactive children unblocked without needing operator-style perms.
Service-scoped inter-cluster calls bypass via the existing
``allow_service_bypass`` path on the new ``require_any_permission``
helper. Builtin admin and operator both already carry these perms;
viewer correctly loses workstream create/close/approve (it already
couldn't do those in spirit).

Implementation
- ``require_any_permission`` (core/auth.py) — OR-semantics variant of
  ``require_permission`` with per-conditional comments documenting the
  security policy at the choke point. 403 body names every accepted
  perm so operators get an actionable remediation
- ``make_{create,close,approve}_handler`` (core/session_routes.py)
  accept ``fallback_permissions: tuple[str, ...]`` — checked only when
  ``cfg.permission_gate is None`` (interactive case). Coord's
  ``permission_gate=_require_admin_coordinator`` continues to take
  precedence on the coord-config side
- Console-side ``create_workstream`` and ``route_create`` inline the
  same OR check before proxying — fail fast on a forbidden request
  without burning a cluster round-trip
- ``route_proxy`` adds a verb-scoped gate on ``approve`` and ``close``
  only; ``send``/``cancel``/``dequeue``/``command``/``plan`` remain
  authenticated-only (pre-existing, out of scope for this audit)

Tests
- New ``TestPermissionGatesOnLifecycle`` (4 tests) in test_server_authz
  pinning 403-without-perm + non-403-with-perm at the node lift sites
- New ``TestRouteProxyPermissionGates`` (5 tests) in
  test_console_routing_proxy covering 403 paths, OR fallback via
  ``admin.coordinator``, and that ``send`` remains ungated
- ``_make_jwt`` helpers in test_server_authz, test_close_reason_
  persistence, test_server_attachments_on_create updated to embed
  operator-shaped perms by default so existing tests continue to
  exercise the post-gate logic rather than 403'ing on the new check

Docs
- ``bootstrap.py`` operator role line corrected to list every perm
  it actually carries (was missing ``tools.approve`` and
  ``conversation.modify``)

* fix(rbac): close lockout + escalation gaps in role-overrides editor

Three issues surfaced by /review of the overlay layer and gate uplift —
all in the RBAC/auth surface, treated as zero-days.

**F-1: lockout guard misses the grant-removal path.** PUT-replace
semantics on ``set_role_overrides`` mean an existing grant of
``admin.roles`` (added via override to e.g. builtin-operator) is
silently dropped when the new payload omits it.  The previous guard
short-circuited on ``"admin.roles" not in revokes`` and never noticed.
Concrete cluster-bricking scenario: grant admin.roles to operator via
override, unassign builtin-admin, click "Reset to default" on operator
→ all users lose admin.roles, recoverable only via SQL.

The rewritten guard simulates the post-PUT effective set on the target
role directly: if ``(baseline | new_grants) - new_revokes`` lacks
admin.roles AND nobody holds it via another role, refuse the change.
The "via another role" question is answered by one bulk query rather
than the prior O(users × roles) round-trip loop.

**F-3: lockout check blocked the event loop on moderate deployments.**
The prior check called ``storage.list_user_roles`` per user and
``storage.effective_role_permissions`` per (user, role) pair —
synchronous SQL inside an async handler.  200 users × 5 roles = 1000
connection cycles long enough to trip reverse-proxy timeouts on a
permission revoke.

Replaced with ``storage.users_with_permission(perm, *,
exclude_role_id)`` — one join over ``user_roles ⋈ roles`` plus one IN
fetch on overrides for the builtin role ids in the result, folded
in-process.  Two queries total, independent of cluster size.  The whole
check now runs under ``asyncio.to_thread`` so even the bulk read
doesn't stall the loop.

**F-2 reframed: admin_assign_role's subset check ignored the overlay.**
The check at lines 6321-6328 reads ``target_role.get("permissions",
"")`` (baseline column) when computing the perms it requires the
caller to hold.  After this branch, an admin.roles holder can grant
e.g. ``model.skills.write`` to builtin-operator via override; an
admin.users holder (who happens to NOT hold that perm) could then
assign operator to a new user, silently escalating the assignee.  The
existing two-person-rule by perm split (admin.roles for catalog edits,
admin.users for assignments) only holds if the assignment-time check
considers the overlay.  Switched ``target_perms`` to
``storage.effective_role_permissions(role_id)["effective"]``.

Note: this PR retains the existing model where admin.roles is the
catalog-edit superuser (admin_create_role, admin_update_role, and now
admin_role_overrides all skip the caller-holds-grants check).  The
two-person rule against escalation lives at the assignment gate, which
this fix reinforces.

**F-7: delete_role left orphaned override rows.** No FK on
``role_permission_overrides.role_id`` (migration 057 omitted FKs to
match the rest of the governance schema).  Added explicit cleanup in
both sqlite + postgresql ``delete_role`` implementations so a
re-seeded role_id (deterministic for builtins on schema reseed) can't
silently inherit stale overrides from the prior occupant.

Tests
- storage: ``test_users_with_permission_bulk`` exercises the new bulk
  helper including ``exclude_role_id`` and overlay folding
- storage: ``test_delete_role_cleans_up_overrides`` pins the F-7 fix
- endpoint: ``test_overrides_lockout_guard_blocks_grant_removal`` is
  the F-1 reproduction — operator-overlay grants admin.roles, builtin-
  admin has it removed, attempting to reset operator's overrides 409s
- endpoint: ``test_assign_role_blocks_escalation_via_overlay_grant``
  pins the F-2 reframed fix — overlay-poisoned operator can't be
  assigned by a caller missing the overlay perms

* refactor(rbac): cleanup batch from /review (#584)

Five non-security findings folded into one commit so the security
batch stays focused.  All consistent with the existing intent of
``feat/builtin-role-overrides``.

**F-4: presence check on ``_effectivePerms``.** ``governance.js`` was
guarding on ``Array.isArray(role.effective) && role.effective.length > 0``,
falling through to splitting ``role.permissions`` (the baseline) when
the array was empty.  For a builtin role whose overrides legitimately
revoke every baseline perm, that path silently rendered the baseline
chips with no override indicators — the inspector lied about what the
role can do.  ``_enrich_role`` always sets ``effective: []``, so
presence is the right sentinel.

**F-5: JS-side drift detector.**  Commit 1 added a Python-side test
asserting ``_VALID_PERMISSIONS`` covers every baseline perm; the
mirror invariant on the frontend went uncaught.  A new perm added to
``_VALID_PERMISSIONS`` without a matching entry in
``_PERMISSION_SECTIONS`` becomes silently un-customizable through the
admin UI (the only documented grant/revoke path).  Test parses the
JS const out via regex and asserts set-equality both directions —
detects "missing in UI" and "extra in UI" so the toggle catalog and
validator can't fork.

**F-6: bulk enrich for ``admin_list_roles``.**  Was ``1 +
2*builtin_count + 1*custom_count`` SELECTs per admin-tab open;
collapsed to one ``IN``-filtered query via new
``storage.effective_role_permissions_bulk(role_ids)``.  Implemented
on both sqlite + postgresql backends following the existing
``effective_role_permissions`` shape.

**F-8: rename ``fallback_permissions`` → ``accepted_permissions``.**
The lift body uses ``if cfg.permission_gate / elif accepted_permissions``
— mutually exclusive — so when ``permission_gate`` is None this IS
the primary gate, not a fallback to anything.  The "fallback" name
suggested a tier-2-after-tier-1 semantic that didn't exist.  Renamed
across ``make_{approve,close,create}_handler`` factories, the three
call sites in ``turnstone/server.py``, and the docstrings.

**F-9: positive lift-level tests for ``admin.coordinator``-only.**
``TestPermissionGatesOnLifecycle`` previously had a single positive
test for ``workstreams.create`` alone, plus negative-403 tests for
each verb without perms.  The OR-fallback to ``admin.coordinator``
(which keeps coord sessions spawning interactive children unblocked)
had no positive coverage at the lift code path — only at the proxy,
which exercises a different verb-dict gate.  Added three tests
(create / close / approve) that pass ``admin.coordinator`` alone and
assert non-403, so a future tightening of the accepted_permissions
tuple can't silently regress coord-driven child workstreams.

Out of scope: nit perf-4 (event-delegation refactor on
``_renderGovRoles``).  ``setSafeHtml`` rebuild is the existing
pattern across every admin tab; rewriting one tab's render path on
this branch would be drive-by inconsistent with the surrounding
codebase.  Filed as a separate concern if the Roles tab grows past
the scale where it bites.

* fix(rbac-ui): aria-expanded + row-click on Roles drawer (#585)

Two Copilot review findings on governance.js:

- Expand button was missing aria-expanded — screen readers couldn't
  announce drawer state.  Now reflects the row's expanded flag.
- Comment said "row + chevron both work" but only the chevron was
  wired.  Added data-expand-role to the row element too so the
  existing handler loop (querySelectorAll on the attribute) picks up
  both — clicking anywhere in the role row toggles the drawer.
  Edit/Delete handlers already stopPropagation so they aren't
  triggered by the row-level click.

* fix(migrations): rebase role_permission_overrides to 058

PR #560 mitigation #1 landed 057_output_assessments_llm_judge.py on
main in parallel; my migration claimed the same number, forking
alembic's head and breaking postgres.  Renumbered to 058 and
re-pointed down_revision at 057 so the chain stays linear.

No behaviour change — same DDL.  Full sweep clean (6730 passed).

* fix(migrations): update 058 revision strings to match filename

Previous commit (ea86aefc) renamed 057_role_permission_overrides.py to
058_* but the in-file revision = "057" / down_revision = "056"
strings stayed — leftover from when the file shipped as 057.  Tests
pass because alembic walks the chain by revision string, and the
strings now correctly read revision = "058" / down_revision = "057"
to make the chain linear with main's 057_output_assessments_llm_judge.

Caught locally before re-running CI; my prior `git mv` + content edit
landed as a staged rename + unstaged modification on the previous
push.
2026-05-24 18:31:23 -07:00

574 lines
20 KiB
Python

"""Tests for console routing proxy endpoints (route_create, route_proxy, route_lookup)."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import httpx
import pytest
from starlette.testclient import TestClient
from turnstone.console.collector import ClusterCollector
from turnstone.console.router import ConsoleRouter, NodeRef
from turnstone.core.rendezvous import NoAvailableNodeError
# Shared test auth — JWT-based
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _test_jwt() -> str:
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
return create_jwt(
user_id="test-routing",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
)
_TEST_AUTH_HEADERS: dict[str, str] = {"Authorization": f"Bearer {_test_jwt()}"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_mock_collector() -> MagicMock:
collector = MagicMock(spec=ClusterCollector)
collector.get_overview.return_value = {
"nodes": 1,
"workstreams": 0,
"states": {"running": 0, "thinking": 0, "attention": 0, "idle": 0, "error": 0},
"aggregate": {"total_tokens": 0, "total_tool_calls": 0},
}
return collector
def _make_mock_router(ready: bool = True) -> MagicMock:
router = MagicMock(spec=ConsoleRouter)
router.is_ready.return_value = ready
router.route.return_value = NodeRef("node-a", "http://a:8080")
router.generate_ws_id_for_node.return_value = "00ff" + "0" * 28
return router
def _make_app(
collector: Any = None,
router: Any = None,
) -> Any:
from turnstone.console.server import _load_static, create_app
_load_static()
return create_app(
collector=collector or _make_mock_collector(),
jwt_secret=_TEST_JWT_SECRET,
router=router,
)
def _make_proxy_post(
status_code: int = 200,
json_data: dict[str, Any] | None = None,
) -> MagicMock:
"""Create a mock for httpx.AsyncClient.post that returns a fixed response."""
data = json_data or {"ws_id": "abc123", "name": "test"}
async def _mock_post(*args: Any, **kwargs: Any) -> httpx.Response:
return httpx.Response(
status_code,
json=data,
request=httpx.Request("POST", args[0] if args else "http://test"),
)
mock_post = MagicMock(side_effect=_mock_post)
return mock_post
def _wire_proxy(app: Any, mock_post: MagicMock | None = None) -> None:
"""Attach a mock proxy_client to the app (lifespan doesn't run in TestClient)."""
if mock_post is None:
mock_post = _make_proxy_post()
mock_proxy = MagicMock(spec=httpx.AsyncClient)
mock_proxy.post = mock_post
# route_proxy uses ``client.request(method, url, ...)`` for path-keyed
# routes (so DELETE on /send proxies through correctly). Wire a
# request-shim that drops the leading method positional and forwards
# to the same mock_post for compatibility.
async def _request_shim(method: str, *args: Any, **kwargs: Any) -> httpx.Response:
return await mock_post(*args, **kwargs)
mock_proxy.request = MagicMock(side_effect=_request_shim)
app.state.proxy_client = mock_proxy
# ---------------------------------------------------------------------------
# Tests — route_create
# ---------------------------------------------------------------------------
class TestRouteCreate:
"""POST /v1/api/route/workstreams/new — create via rendezvous routing."""
@pytest.fixture()
def client(self):
router = _make_mock_router()
app = _make_app(router=router)
_wire_proxy(app, _make_proxy_post(json_data={"ws_id": "abc123", "name": "test"}))
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_route_create_proxies_to_node(self, client):
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
assert data["ws_id"] == "abc123"
def test_route_create_injects_node_url(self, client):
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
assert data["node_url"] == "http://a:8080"
assert data["node_id"] == "node-a"
def test_route_create_resume_ws(self):
"""resume_ws should route to the node that owns the old workstream."""
router = _make_mock_router()
router.route.return_value = NodeRef("node-b", "http://b:8080")
app = _make_app(router=router)
_wire_proxy(app, _make_proxy_post(json_data={"ws_id": "old_ws_resumed", "name": "resumed"}))
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/v1/api/route/workstreams/new",
json={"resume_ws": "old_ws_id"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
assert data["node_url"] == "http://b:8080"
assert data["node_id"] == "node-b"
# route() should have been called with the old ws_id
router.route.assert_called_with("old_ws_id")
client.close()
def test_route_create_target_node(self):
"""target_node should generate a ws_id that hashes to that node."""
router = _make_mock_router()
router.generate_ws_id_for_node.return_value = "00ff" + "0" * 28
router.route.return_value = NodeRef("node-c", "http://c:8080")
app = _make_app(router=router)
_wire_proxy(
app,
_make_proxy_post(json_data={"ws_id": "00ff" + "0" * 28, "name": "pinned"}),
)
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/v1/api/route/workstreams/new",
json={"target_node": "node-c"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
assert data["node_id"] == "node-c"
router.generate_ws_id_for_node.assert_called_with("node-c")
client.close()
def test_route_create_routing_strategy_rendezvous(self, client):
"""Default fan-out (no resume_ws / no target_node) reports
routing_strategy='rendezvous' so the coordinator's spawn tool
can explain why the node was chosen."""
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["routing_strategy"] == "rendezvous"
def test_route_create_routing_strategy_target_node(self):
router = _make_mock_router()
router.generate_ws_id_for_node.return_value = "00ff" + "0" * 28
router.route.return_value = NodeRef("node-c", "http://c:8080")
app = _make_app(router=router)
_wire_proxy(app, _make_proxy_post(json_data={"ws_id": "00ff" + "0" * 28, "name": "pinned"}))
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/v1/api/route/workstreams/new",
json={"target_node": "node-c"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["routing_strategy"] == "target_node"
client.close()
def test_route_create_routing_strategy_resume(self):
router = _make_mock_router()
router.route.return_value = NodeRef("node-b", "http://b:8080")
app = _make_app(router=router)
_wire_proxy(app, _make_proxy_post(json_data={"ws_id": "old_ws_resumed", "name": "resumed"}))
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/v1/api/route/workstreams/new",
json={"resume_ws": "old_ws_id"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["routing_strategy"] == "resume"
client.close()
class TestRouteCreate503Retry:
"""503 retry logic in route_create."""
def test_route_create_503_retries_on_different_node(self):
"""If the first node returns 503, retry with a new ws_id targeting a different node."""
router = _make_mock_router()
call_count = 0
def side_effect_route(ws_id: str) -> NodeRef:
nonlocal call_count
call_count += 1
if call_count <= 1:
# First call returns node-a
return NodeRef("node-a", "http://a:8080")
# Subsequent calls return node-b (different node for retry)
return NodeRef("node-b", "http://b:8080")
router.route.side_effect = side_effect_route
app = _make_app(router=router)
post_count = 0
async def _mock_post(*args: Any, **kwargs: Any) -> httpx.Response:
nonlocal post_count
post_count += 1
if post_count == 1:
return httpx.Response(
503,
json={"error": "overloaded"},
request=httpx.Request("POST", args[0] if args else "http://test"),
)
return httpx.Response(
200,
json={"ws_id": "retry_ws", "name": "retry"},
request=httpx.Request("POST", args[0] if args else "http://test"),
)
mock_proxy = MagicMock(spec=httpx.AsyncClient)
mock_proxy.post = MagicMock(side_effect=_mock_post)
app.state.proxy_client = mock_proxy
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
assert data["ws_id"] == "retry_ws"
assert data["node_id"] == "node-b"
assert post_count == 2
client.close()
# ---------------------------------------------------------------------------
# Tests — route_proxy
# ---------------------------------------------------------------------------
class TestRouteProxy:
"""POST /v1/api/route/workstreams/{ws_id}/<verb> (and the surviving
body-keyed plan/command routes)."""
@pytest.fixture()
def client(self):
router = _make_mock_router()
app = _make_app(router=router)
_wire_proxy(app, _make_proxy_post(json_data={"status": "ok"}))
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_route_proxy_send(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc123/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
# Verify upstream URL was /v1/api/workstreams/abc123/send
# (not /v1/api/route/workstreams/abc123/send).
mock_request = client.app.state.proxy_client.request
call_args = mock_request.call_args
# request is called as ``request(method, url, ...)`` — url is the
# second positional arg.
upstream_url = call_args[0][1]
assert "/v1/api/workstreams/abc123/send" in upstream_url
assert "/route/" not in upstream_url
def test_route_proxy_approve(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc123/approve",
json={"approved": True},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
def test_route_proxy_cancel(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc123/cancel",
json={},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
def test_route_proxy_command(self, client):
resp = client.post(
"/v1/api/route/command",
json={"ws_id": "abc123", "command": "status"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
def test_route_proxy_close(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc123/close",
json={},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
class TestRouteProxyPermissionGates:
"""``route_proxy`` was pre-existing infra that forwarded blindly —
any authenticated caller could send/approve/cancel/close. PR
adding 057_role_permission_overrides added verb-scoped gates on
approve + close (the verbs that had vestigial perms in
``_VALID_PERMISSIONS`` with no enforcement site). These tests
pin the new shape and the OR fallback to ``admin.coordinator``."""
@pytest.fixture()
def client(self):
router = _make_mock_router()
app = _make_app(router=router)
_wire_proxy(app, _make_proxy_post(json_data={"status": "ok"}))
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
@staticmethod
def _hdr(*, perms: frozenset[str] = frozenset()) -> dict[str, str]:
# Plain user — no service scope, so the bypass doesn't kick in;
# just the perms passed by the test.
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
return {
"Authorization": (
"Bearer "
+ create_jwt(
user_id="test-user",
scopes=frozenset({"read", "write", "approve"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
permissions=perms,
)
)
}
def test_approve_without_perm_returns_403(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc123/approve",
json={"approved": True},
headers=self._hdr(),
)
assert resp.status_code == 403
assert "tools.approve" in resp.json()["error"]
def test_close_without_perm_returns_403(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc123/close",
json={},
headers=self._hdr(),
)
assert resp.status_code == 403
assert "workstreams.close" in resp.json()["error"]
def test_approve_with_tools_approve_passes(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc123/approve",
json={"approved": True},
headers=self._hdr(perms=frozenset({"tools.approve"})),
)
assert resp.status_code == 200
def test_close_with_admin_coordinator_passes(self, client):
# The OR fallback: coord sessions can drive close on
# interactive children without holding workstreams.close.
resp = client.post(
"/v1/api/route/workstreams/abc123/close",
json={},
headers=self._hdr(perms=frozenset({"admin.coordinator"})),
)
assert resp.status_code == 200
def test_send_remains_authenticated_only(self, client):
# send/cancel/dequeue/command/plan are unchanged — no new gate.
resp = client.post(
"/v1/api/route/workstreams/abc123/send",
json={"message": "hi"},
headers=self._hdr(),
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Tests — route_lookup
# ---------------------------------------------------------------------------
class TestRouteLookup:
"""GET /v1/api/route — look up which node owns a workstream."""
@pytest.fixture()
def client(self):
router = _make_mock_router()
app = _make_app(router=router)
app.state.proxy_client = MagicMock(spec=httpx.AsyncClient)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_route_lookup(self, client):
resp = client.get("/v1/api/route?ws_id=abc123", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 200
data = resp.json()
assert data["node_url"] == "http://a:8080"
assert data["node_id"] == "node-a"
def test_route_lookup_missing_ws_id(self, client):
resp = client.get("/v1/api/route", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 400
assert "ws_id" in resp.json()["error"]
# ---------------------------------------------------------------------------
# Tests — not ready / no router -> 503
# ---------------------------------------------------------------------------
class TestRouteNotReady:
"""When router is None or empty cache, all routing endpoints return 503."""
@pytest.fixture()
def client_no_router(self):
app = _make_app(router=None)
app.state.proxy_client = MagicMock(spec=httpx.AsyncClient)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
@pytest.fixture()
def client_empty_cache(self):
router = _make_mock_router(ready=False)
app = _make_app(router=router)
app.state.proxy_client = MagicMock(spec=httpx.AsyncClient)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_route_create_no_router_503(self, client_no_router):
resp = client_no_router.post(
"/v1/api/route/workstreams/new",
json={"name": "test"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
def test_route_create_empty_cache_503(self, client_empty_cache):
resp = client_empty_cache.post(
"/v1/api/route/workstreams/new",
json={"name": "test"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
def test_route_proxy_no_router_503(self, client_no_router):
resp = client_no_router.post(
"/v1/api/route/workstreams/abc/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
def test_route_lookup_no_router_503(self, client_no_router):
resp = client_no_router.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 503
def test_route_proxy_empty_cache_503(self, client_empty_cache):
resp = client_empty_cache.post(
"/v1/api/route/workstreams/abc/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
def test_route_lookup_empty_cache_503(self, client_empty_cache):
resp = client_empty_cache.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 503
# ---------------------------------------------------------------------------
# Tests — NoAvailableNodeError handling
# ---------------------------------------------------------------------------
class TestRouteNoNode:
"""When router.route() raises NoAvailableNodeError, endpoints return 503."""
@pytest.fixture()
def client(self):
router = _make_mock_router()
router.route.side_effect = NoAvailableNodeError("bucket 0 not assigned")
app = _make_app(router=router)
app.state.proxy_client = MagicMock(spec=httpx.AsyncClient)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_route_create_no_node_503(self, client):
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "test"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
assert "No available node" in resp.json()["error"]
def test_route_proxy_no_node_503(self, client):
resp = client.post(
"/v1/api/route/workstreams/abc/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
def test_route_lookup_no_node_503(self, client):
resp = client.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 503