mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
d068366a61
* 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.
574 lines
20 KiB
Python
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
|