Files
turnstone/tests/test_coordinator_proxy_auth.py
T
Patrick Buckley 7a06f5e8bc refactor(session): make ModelLane the provider boundary (#979) (#989)
* refactor(session): make ModelLane the provider boundary (#979)

## Summary

This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot.

- Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding.
- Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call.
- Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references.
- Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results.
- Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts.

## Validation

- SQLite suite: 11,188 passed, 9 skipped, 10 deselected
- PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected
- Live backend: 3 passed
- SSE recovery: 6 passed; browser recovery harness passed all scenarios
- Ruff: clean; 595 files correctly formatted
- mypy: 243 source files clean
- TypeScript: typecheck/build and 35 tests passed
- OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte
- `git diff --check` and Git LFS integrity clean

Closes #979.

* fix(deps): update nanoid for GHSA-2v37-7h3g-55p8

Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation.

Validation:
- npm ci
- npm audit --audit-level=moderate: 0 vulnerabilities
- TypeScript typecheck and build
- TypeScript tests: 35 passed

* fix(test): assert canonical model registry URLs

Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation.

Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
2026-08-08 16:13:35 -07:00

122 lines
4.2 KiB
Python

"""Tests for console _proxy_auth_headers preserving the coordinator src claim.
Verifies C8 of the coordinator plan: when a console handler processes an
inbound request authenticated with a coordinator-minted JWT (``src ==
"coordinator"``), the upstream JWT the console mints for the proxied
request preserves that source plus the ``coord_ws_id`` custom claim.
For non-coordinator inbound tokens the re-mint still uses
``"console-proxy"`` as before — the existing behaviour is unchanged.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import jwt as pyjwt
from turnstone.console.server import _proxy_auth_headers
from turnstone.core.auth import JWT_AUD_SERVER, AuthResult
_SECRET = "x" * 64
def _build_request(auth_result: AuthResult | None):
"""Minimal Request-alike for _proxy_auth_headers."""
state = SimpleNamespace(auth_result=auth_result)
app_state = SimpleNamespace(jwt_secret=_SECRET, proxy_token_mgr=None)
app = MagicMock()
app.state = app_state
req = MagicMock()
req.state = state
req.app = app
return req
def _decode(headers: dict[str, str]) -> dict:
token = headers["Authorization"].removeprefix("Bearer ")
return pyjwt.decode(token, _SECRET, algorithms=["HS256"], audience=JWT_AUD_SERVER)
def test_console_proxy_uses_console_proxy_source_by_default():
"""Non-coordinator inbound tokens still mint src='console-proxy'."""
auth = AuthResult(
user_id="user-1",
scopes=frozenset({"write"}),
token_source="jwt",
permissions=frozenset(),
)
headers = _proxy_auth_headers(_build_request(auth))
payload = _decode(headers)
assert payload["src"] == "console-proxy"
assert "coord_ws_id" not in payload
def test_console_service_source_is_preserved_for_trusted_forwarding():
"""Only the console service identity may retain ``src=console``."""
auth = AuthResult(
user_id="console-service",
scopes=frozenset({"read", "write", "service"}),
token_source="console",
permissions=frozenset({"workstreams.create"}),
)
payload = _decode(_proxy_auth_headers(_build_request(auth)))
assert payload["src"] == "console"
assert set(payload["scopes"].split(",")) == {"read", "write", "service"}
def test_unscoped_console_claim_is_demoted_to_console_proxy():
"""An ordinary principal cannot gain owner-override trust through ``src``."""
auth = AuthResult(
user_id="ordinary-user",
scopes=frozenset({"read", "write"}),
token_source="console",
permissions=frozenset({"workstreams.create"}),
)
payload = _decode(_proxy_auth_headers(_build_request(auth)))
assert payload["src"] == "console-proxy"
def test_coordinator_source_is_preserved_on_remint():
"""Inbound src='coordinator' → outbound src='coordinator'."""
auth = AuthResult(
user_id="user-1",
scopes=frozenset({"approve"}),
token_source="coordinator",
permissions=frozenset({"admin.coordinator"}),
extra_claims={"coord_ws_id": "coord-42"},
)
headers = _proxy_auth_headers(_build_request(auth))
payload = _decode(headers)
assert payload["src"] == "coordinator"
assert payload["coord_ws_id"] == "coord-42"
def test_coord_ws_id_absent_when_not_in_inbound_claims():
"""Defensive: if the inbound token is src=coordinator but missing the
coord_ws_id claim (shouldn't happen in practice), the re-mint skips
the custom claim rather than panicking."""
auth = AuthResult(
user_id="user-1",
scopes=frozenset({"write"}),
token_source="coordinator",
permissions=frozenset(),
)
headers = _proxy_auth_headers(_build_request(auth))
payload = _decode(headers)
assert payload["src"] == "coordinator"
assert "coord_ws_id" not in payload
def test_empty_auth_falls_back_to_service_token_or_empty():
"""Without auth_result.user_id, falls through to ServiceTokenManager."""
auth = AuthResult(
user_id="",
scopes=frozenset(),
token_source="config",
permissions=frozenset(),
)
# No proxy_token_mgr configured → empty headers.
headers = _proxy_auth_headers(_build_request(auth))
assert headers == {}