mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(server): opt-in server.require_project gate
Add an opt-in, default-off `server.require_project` setting. When an admin enables it, creating an interactive chat is refused unless it is filed under a project. The feature is inert and byte-identical when off, and can only ever fail toward "off" (a missing config store or unset key reads as disabled). - settings_registry: server.require_project (bool, default False, live read). - auth: require_project_enabled + require_project_denies_create predicates (service scope / coordinator token_source exempt; NOT admin.coordinator), plus REQUIRE_PROJECT_ERROR / REQUIRE_PROJECT_CODE. - node create gate via a declarative cfg.create_gate_require_project (wired True on the interactive mount only; coordinator spawns stay ungated). - fork/resume: a fork's project is structurally its source's. Any explicit project_id is discarded, so a fork can never be re-filed under an unrelated project (which would move its copied history across a tenancy boundary). Inaccessible / projectless / nonexistent sources are uniform on body and status, so there is no cross-tenant oracle. - console cluster-create proxy surfaces only the coded require_project 400 and masks every other node outcome (401/429/3xx/5xx, un-coded 400) to a sanitized 502, guarding both body reads. - list_projects advisory field + projects.js requireProject() (fail-open). - fresh-create project picker requires an explicit project choice under the flag (no silent auto-select); forks hide the picker (inheritance is server-enforced) and get an accurate refusal message. - tests: predicate matrix, resume-inheritance oracle discriminators, console masking, and end-to-end node-gate mount wiring.
This commit is contained in:
@@ -430,6 +430,40 @@ def mock_openai_client():
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_config_store():
|
||||
"""Factory for a lightweight ConfigStore double.
|
||||
|
||||
``make_config_store(**overrides)`` returns an object whose ``.get(key)``
|
||||
yields the override when present, else the registered SettingDef default —
|
||||
mirroring the real :meth:`ConfigStore.get` fail-open (a bool setting reads
|
||||
as its ``False`` default on a miss, never ``None``). Shared by the
|
||||
``server.require_project`` gate / advisory tests.
|
||||
"""
|
||||
|
||||
_unset = object()
|
||||
|
||||
def _make(**overrides: Any) -> Any:
|
||||
from turnstone.core.settings_registry import SETTINGS
|
||||
|
||||
class _ConfigStoreDouble:
|
||||
def get(self, key: str, default: Any = _unset) -> Any:
|
||||
# Mirror ConfigStore.get precedence exactly: cache (overrides)
|
||||
# first, then a caller-supplied default, then the registry
|
||||
# default, then None — so a reused caller passing an explicit
|
||||
# default for an unset key gets the same value production would.
|
||||
if key in overrides:
|
||||
return overrides[key]
|
||||
if default is not _unset:
|
||||
return default
|
||||
defn = SETTINGS.get(key)
|
||||
return defn.default if defn else None
|
||||
|
||||
return _ConfigStoreDouble()
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_policy_cache():
|
||||
"""Drop the in-process tool-policy cache between tests.
|
||||
|
||||
@@ -2311,3 +2311,49 @@ def test_console_has_matching_pane_hotkeys() -> None:
|
||||
assert '"Fork"' not in index and "New workstream" not in index, (
|
||||
"Fork + New are intentionally omitted on the console"
|
||||
)
|
||||
|
||||
|
||||
def test_fork_hides_project_picker() -> None:
|
||||
"""A fork must NOT show a project picker. A fork inherits its source's project
|
||||
(enforced server-side), and a re-fileable fork picker was a cross-tenant
|
||||
history-relocation vector — so showNewWsModal hides the picker for forks and
|
||||
the "Keep source's project" fork option is gone."""
|
||||
body = _APP_JS.read_text(encoding="utf-8")
|
||||
assert "projSelect.hidden = !!_forkFromWsId" in body, (
|
||||
"the new-ws project picker must be hidden for forks"
|
||||
)
|
||||
assert "Keep source's project" not in body, (
|
||||
"the fork project picker (and its 'Keep source's project' option) must be removed"
|
||||
)
|
||||
|
||||
|
||||
def test_submit_gates_project_on_fork_flag() -> None:
|
||||
"""submitNewWs must send body.project_id ONLY for a fresh create
|
||||
(!_forkFromWsId) — a fork never sends a project (its project is the source's,
|
||||
enforced server-side). Not gated on picker visibility or a requireProject()
|
||||
re-read."""
|
||||
body = _APP_JS.read_text(encoding="utf-8")
|
||||
m = re.search(r"function submitNewWs\(\)\s*\{(.*?)\n\}", body, re.S)
|
||||
assert m is not None, "could not locate submitNewWs"
|
||||
fn = m.group(1)
|
||||
assert "TurnstoneProjects.requireProject" not in fn, (
|
||||
"submit must not re-read the requireProject() advisory"
|
||||
)
|
||||
assert re.search(r"project_id && !_forkFromWsId", fn), (
|
||||
"submit must gate project_id on !_forkFromWsId (a fork never sends a project)"
|
||||
)
|
||||
|
||||
|
||||
def test_strict_picker_requires_explicit_pick() -> None:
|
||||
"""Under require_project the fresh picker must NOT auto-select the first
|
||||
project (which silently mis-files a required chat under a possibly-shared
|
||||
project) — it offers a 'Select a project…' prompt so the user consciously
|
||||
chooses."""
|
||||
body = _APP_JS.read_text(encoding="utf-8")
|
||||
assert "Select a project" in body, (
|
||||
"strict picker must offer an explicit 'Select a project…' prompt"
|
||||
)
|
||||
m = re.search(r"function _reconcileRequiredProjectSelection\(sel\)\s*\{(.*?)\n\}", body, re.S)
|
||||
assert m is not None, "could not locate _reconcileRequiredProjectSelection"
|
||||
fn = m.group(1)
|
||||
assert "real[0]" not in fn, "strict picker must not auto-select the first project"
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
"""``server.require_project`` — the opt-in, default-off gate refusing projectless
|
||||
interactive creates.
|
||||
|
||||
Three surfaces:
|
||||
* the predicate matrix (``require_project_enabled`` / ``require_project_denies_create``);
|
||||
* the fork/resume project inheritance + the cross-tenant 403-vs-400 oracle in the
|
||||
interactive create validator (``_interactive_create_validate_request``);
|
||||
* the console cluster-create proxy's surface-only-require_project / mask-everything
|
||||
-else policy (``create_workstream``).
|
||||
|
||||
Validator tests drive the coroutine synchronously via ``asyncio.run`` so they need no
|
||||
async-plugin marker. Storage is a MagicMock patched onto the singleton getter that both
|
||||
the RAW resume-resolve and ``ensure_project_attachable`` read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Predicate matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Auth:
|
||||
"""Minimal AuthResult stand-in: ``has_scope`` + ``token_source``."""
|
||||
|
||||
def __init__(
|
||||
self, scopes: tuple[str, ...] = (), token_source: str = "jwt", user_id: str = "alice"
|
||||
) -> None:
|
||||
self._scopes = frozenset(scopes)
|
||||
self.token_source = token_source
|
||||
self.user_id = user_id
|
||||
|
||||
def has_scope(self, scope: str) -> bool:
|
||||
return scope in self._scopes
|
||||
|
||||
|
||||
class TestRequireProjectPredicate:
|
||||
def test_enabled_off_by_default(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_enabled
|
||||
|
||||
assert require_project_enabled(make_config_store()) is False
|
||||
|
||||
def test_enabled_when_set(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_enabled
|
||||
|
||||
assert (
|
||||
require_project_enabled(make_config_store(**{"server.require_project": True})) is True
|
||||
)
|
||||
|
||||
def test_enabled_none_config_store_fails_open(self) -> None:
|
||||
from turnstone.core.auth import require_project_enabled
|
||||
|
||||
assert require_project_enabled(None) is False
|
||||
|
||||
def test_denies_projectless_when_on(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
cs = make_config_store(**{"server.require_project": True})
|
||||
assert require_project_denies_create(cs, _Auth(), "") is True
|
||||
|
||||
def test_allows_when_off(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
# Flag off: even a projectless create is allowed (byte-identical to today).
|
||||
assert require_project_denies_create(make_config_store(), _Auth(), "") is False
|
||||
|
||||
def test_allows_none_config_store(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
# Storage unwired: fail open.
|
||||
assert require_project_denies_create(None, _Auth(), "") is False
|
||||
|
||||
def test_allows_with_project(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
cs = make_config_store(**{"server.require_project": True})
|
||||
assert require_project_denies_create(cs, _Auth(), "p1") is False
|
||||
|
||||
def test_whitespace_project_is_projectless(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
cs = make_config_store(**{"server.require_project": True})
|
||||
assert require_project_denies_create(cs, _Auth(), " ") is True
|
||||
|
||||
def test_service_scope_exempt(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
cs = make_config_store(**{"server.require_project": True})
|
||||
assert require_project_denies_create(cs, _Auth(scopes=("service",)), "") is False
|
||||
|
||||
def test_coordinator_source_exempt(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
cs = make_config_store(**{"server.require_project": True})
|
||||
assert require_project_denies_create(cs, _Auth(token_source="coordinator"), "") is False
|
||||
|
||||
def test_console_proxy_human_not_exempt(self, make_config_store: Any) -> None:
|
||||
# The normal proxied human carries their OWN scopes (no service) and
|
||||
# token_source "console-proxy" — gated, not exempt.
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
cs = make_config_store(**{"server.require_project": True})
|
||||
auth = _Auth(scopes=("read", "write"), token_source="console-proxy")
|
||||
assert require_project_denies_create(cs, auth, "") is True
|
||||
|
||||
def test_admin_operator_not_exempt(self, make_config_store: Any) -> None:
|
||||
# An operator carries admin-derived scopes (approve) but never `service`,
|
||||
# so `admin.coordinator` humans are gated — the predicate keys on scope /
|
||||
# token_source, never a permission.
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
cs = make_config_store(**{"server.require_project": True})
|
||||
auth = _Auth(scopes=("read", "write", "approve"), token_source="jwt")
|
||||
assert require_project_denies_create(cs, auth, "") is True
|
||||
|
||||
def test_none_auth_denied_when_projectless(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
cs = make_config_store(**{"server.require_project": True})
|
||||
assert require_project_denies_create(cs, None, "") is True
|
||||
|
||||
def test_none_auth_allowed_with_project(self, make_config_store: Any) -> None:
|
||||
from turnstone.core.auth import require_project_denies_create
|
||||
|
||||
cs = make_config_store(**{"server.require_project": True})
|
||||
assert require_project_denies_create(cs, None, "p1") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fork/resume inheritance + the 403-vs-400 cross-tenant oracle (node validator)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _src_storage(
|
||||
*,
|
||||
project_id: str | None = None,
|
||||
project_visibility: str = "private",
|
||||
project_owner: str = "other",
|
||||
members: tuple[str, ...] = (),
|
||||
resolve_none: bool = False,
|
||||
get_project_missing: bool = False,
|
||||
) -> MagicMock:
|
||||
"""Storage double for the resume source: resolve + get_workstream (RAW) and
|
||||
the get_project/is_project_member surface ``ensure_project_attachable`` reads."""
|
||||
storage = MagicMock()
|
||||
storage.resolve_workstream.side_effect = lambda _x: None if resolve_none else "src-canon"
|
||||
storage.get_workstream.return_value = {
|
||||
"ws_id": "src-canon",
|
||||
"project_id": project_id,
|
||||
"user_id": "other",
|
||||
}
|
||||
if get_project_missing or project_id is None:
|
||||
storage.get_project.return_value = None
|
||||
else:
|
||||
storage.get_project.return_value = {
|
||||
"project_id": project_id,
|
||||
"name": "P",
|
||||
"owner_id": project_owner,
|
||||
"visibility": project_visibility,
|
||||
"state": "active",
|
||||
}
|
||||
storage.is_project_member.side_effect = lambda pid, uid: uid in members
|
||||
return storage
|
||||
|
||||
|
||||
def _validate(monkeypatch: Any, body: dict[str, Any], uid: str, cs: Any, storage: Any) -> Any:
|
||||
"""Run ``_interactive_create_validate_request`` with a patched storage getter."""
|
||||
import turnstone.server as server_mod
|
||||
|
||||
monkeypatch.setattr("turnstone.core.storage._registry.get_storage", lambda: storage)
|
||||
req = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(config_store=cs)))
|
||||
return asyncio.run(server_mod._interactive_create_validate_request(req, body, uid, []))
|
||||
|
||||
|
||||
class TestResumeInheritanceOracle:
|
||||
def _on(self, make_config_store: Any) -> Any:
|
||||
return make_config_store(**{"server.require_project": True})
|
||||
|
||||
def test_inherits_attachable_source_project(
|
||||
self, monkeypatch: Any, make_config_store: Any
|
||||
) -> None:
|
||||
storage = _src_storage(project_id="ppub", project_visibility="public")
|
||||
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
|
||||
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
|
||||
assert res is None
|
||||
assert body["project_id"] == "ppub" # inherited (attachable)
|
||||
|
||||
def test_member_of_private_source_inherits(
|
||||
self, monkeypatch: Any, make_config_store: Any
|
||||
) -> None:
|
||||
storage = _src_storage(
|
||||
project_id="psecret", project_visibility="private", members=("alice",)
|
||||
)
|
||||
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
|
||||
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
|
||||
assert res is None
|
||||
assert body["project_id"] == "psecret"
|
||||
|
||||
def test_private_source_no_403_oracle(self, monkeypatch: Any, make_config_store: Any) -> None:
|
||||
# Source under a private project alice can't access → MUST NOT surface a
|
||||
# distinguishable 403; drop to projectless so the gate 400s it uniformly.
|
||||
storage = _src_storage(project_id="psecret", project_visibility="private", members=())
|
||||
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
|
||||
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
|
||||
assert res is None # NOT a 403 JSONResponse
|
||||
assert body.get("project_id", "") == ""
|
||||
|
||||
def test_projectless_source_no_inherit(self, monkeypatch: Any, make_config_store: Any) -> None:
|
||||
storage = _src_storage(project_id=None)
|
||||
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
|
||||
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
|
||||
assert res is None
|
||||
assert body.get("project_id", "") == ""
|
||||
|
||||
def test_nonexistent_source_no_inherit(self, monkeypatch: Any, make_config_store: Any) -> None:
|
||||
storage = _src_storage(resolve_none=True)
|
||||
body: dict[str, Any] = {"resume_ws": "ghost", "kind": "interactive"}
|
||||
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
|
||||
assert res is None
|
||||
assert body.get("project_id", "") == ""
|
||||
|
||||
def test_dangling_source_project_no_oracle(
|
||||
self, monkeypatch: Any, make_config_store: Any
|
||||
) -> None:
|
||||
# Source's project was deleted → attach 400 → drop (uniform with the rest).
|
||||
storage = _src_storage(project_id="pdead", get_project_missing=True)
|
||||
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
|
||||
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
|
||||
assert res is None
|
||||
assert body.get("project_id", "") == ""
|
||||
|
||||
def test_private_and_projectless_indistinguishable(
|
||||
self, monkeypatch: Any, make_config_store: Any
|
||||
) -> None:
|
||||
# The R1 core: private-source and projectless-source produce IDENTICAL
|
||||
# observable outcomes — no cross-tenant oracle.
|
||||
cs = self._on(make_config_store)
|
||||
b_priv: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
|
||||
_validate(monkeypatch, b_priv, "alice", cs, _src_storage(project_id="psecret"))
|
||||
b_none: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
|
||||
_validate(monkeypatch, b_none, "alice", cs, _src_storage(project_id=None))
|
||||
assert b_priv.get("project_id", "") == b_none.get("project_id", "") == ""
|
||||
|
||||
def test_flag_off_never_resolves(self, monkeypatch: Any, make_config_store: Any) -> None:
|
||||
# Byte-identical when off: the source is never resolved, nothing inherited.
|
||||
storage = _src_storage(project_id="ppub", project_visibility="public")
|
||||
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
|
||||
res = _validate(monkeypatch, body, "alice", make_config_store(), storage)
|
||||
assert res is None
|
||||
assert body.get("project_id", "") == ""
|
||||
storage.resolve_workstream.assert_not_called()
|
||||
|
||||
def test_explicit_project_discarded_for_projected_source(
|
||||
self, monkeypatch: Any, make_config_store: Any
|
||||
) -> None:
|
||||
# A fork DISCARDS any explicit project_id and inherits its SOURCE's
|
||||
# project — an explicit pick can never re-file a fork's history ([1]).
|
||||
storage = _src_storage(project_id="ppub", project_visibility="public")
|
||||
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive", "project_id": "pchosen"}
|
||||
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
|
||||
assert res is None
|
||||
assert body["project_id"] == "ppub" # overridden to the source's project
|
||||
storage.resolve_workstream.assert_called() # a fork always resolves its source
|
||||
|
||||
def test_explicit_project_discarded_projectless_source(
|
||||
self, monkeypatch: Any, make_config_store: Any
|
||||
) -> None:
|
||||
# The safe-vs-leaky discriminator: a fork of a PROJECTLESS source carrying
|
||||
# an explicit owned project_id must NOT file under the pick — the pick is
|
||||
# discarded, nothing inherited, so it funnels to the uniform projectless
|
||||
# "" (400 downstream), indistinguishable from inaccessible/nonexistent.
|
||||
storage = _src_storage(project_id=None)
|
||||
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive", "project_id": "powned"}
|
||||
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
|
||||
assert res is None
|
||||
assert body.get("project_id", "") == ""
|
||||
|
||||
def test_explicit_project_discarded_nonexistent_source(
|
||||
self, monkeypatch: Any, make_config_store: Any
|
||||
) -> None:
|
||||
# Same discriminator for a NONEXISTENT source + explicit owned pid: "".
|
||||
storage = _src_storage(resolve_none=True)
|
||||
body: dict[str, Any] = {"resume_ws": "ghost", "kind": "interactive", "project_id": "powned"}
|
||||
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
|
||||
assert res is None
|
||||
assert body.get("project_id", "") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Console cluster-create proxy: surface only require_project, mask the rest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CONSOLE_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _console_headers() -> dict[str, str]:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
tok = create_jwt(
|
||||
user_id="op",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_CONSOLE_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
return {"Authorization": f"Bearer {tok}"}
|
||||
|
||||
|
||||
def _node_resp(
|
||||
status_code: int, json_body: dict[str, Any] | None = None, content: bytes | None = None
|
||||
) -> httpx.Response:
|
||||
req = httpx.Request("POST", "http://a:8080/v1/api/workstreams/new")
|
||||
if content is not None:
|
||||
return httpx.Response(status_code, content=content, request=req)
|
||||
return httpx.Response(status_code, json=json_body if json_body is not None else {}, request=req)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _console_client(
|
||||
node_response: httpx.Response | None = None, raise_exc: BaseException | None = None
|
||||
) -> Any:
|
||||
"""A console TestClient whose proxied node create returns *node_response* (an
|
||||
``httpx.Response``) or raises *raise_exc*. Lifespan is not entered (matches the
|
||||
existing cluster-create tests) so the manually-attached proxy_client survives."""
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
collector.get_node_detail.return_value = {
|
||||
"node_id": "node-a",
|
||||
"server_url": "http://a:8080",
|
||||
"health": {},
|
||||
"workstreams": [],
|
||||
"aggregate": {},
|
||||
"reachable": True,
|
||||
}
|
||||
collector.get_nodes.return_value = (
|
||||
[{"node_id": "node-a", "reachable": True, "max_ws": 10, "ws_total": 1}],
|
||||
1,
|
||||
)
|
||||
collector.get_all_nodes.side_effect = lambda: collector.get_nodes.return_value[0]
|
||||
collector.get_overview.return_value = {
|
||||
"nodes": 1,
|
||||
"workstreams": 0,
|
||||
"states": {"running": 0, "idle": 0, "thinking": 0, "attention": 0, "error": 0},
|
||||
"aggregate": {"total_tokens": 0, "total_tool_calls": 0},
|
||||
}
|
||||
|
||||
_load_static()
|
||||
app = create_app(collector=collector, jwt_secret=_CONSOLE_JWT_SECRET)
|
||||
|
||||
async def _mock_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
|
||||
if raise_exc is not None:
|
||||
raise raise_exc
|
||||
assert node_response is not None
|
||||
return node_response
|
||||
|
||||
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, headers=_console_headers())
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _create(client: Any) -> httpx.Response:
|
||||
return client.post("/v1/api/cluster/workstreams/new", json={"node_id": "node-a", "name": "x"})
|
||||
|
||||
|
||||
class TestConsoleRequireProjectSurfacing:
|
||||
def test_require_project_400_surfaced(self) -> None:
|
||||
from turnstone.core.auth import REQUIRE_PROJECT_CODE, REQUIRE_PROJECT_ERROR
|
||||
|
||||
node = _node_resp(400, {"error": REQUIRE_PROJECT_ERROR, "code": REQUIRE_PROJECT_CODE})
|
||||
with _console_client(node) as client:
|
||||
resp = _create(client)
|
||||
assert resp.status_code == 400
|
||||
data = resp.json()
|
||||
assert data["code"] == REQUIRE_PROJECT_CODE
|
||||
assert data["error"] == REQUIRE_PROJECT_ERROR
|
||||
|
||||
def test_uncoded_400_masked_no_leak(self) -> None:
|
||||
node = _node_resp(400, {"error": "cannot fork abc: SECRETPERSONA missing"})
|
||||
with _console_client(node) as client:
|
||||
resp = _create(client)
|
||||
assert resp.status_code == 502
|
||||
assert "SECRETPERSONA" not in resp.text
|
||||
assert resp.json()["error"] == "Dispatch to node node-a failed"
|
||||
|
||||
def test_other_coded_400_masked(self) -> None:
|
||||
node = _node_resp(400, {"error": "too many files", "code": "too_many"})
|
||||
with _console_client(node) as client:
|
||||
resp = _create(client)
|
||||
assert resp.status_code == 502
|
||||
|
||||
def test_401_masked(self) -> None:
|
||||
with _console_client(_node_resp(401, {"error": "unauthorized"})) as client:
|
||||
resp = _create(client)
|
||||
assert resp.status_code == 502
|
||||
|
||||
def test_429_masked(self) -> None:
|
||||
with _console_client(_node_resp(429, {"error": "capacity"})) as client:
|
||||
resp = _create(client)
|
||||
assert resp.status_code == 502
|
||||
|
||||
def test_attach_denied_403_masked(self) -> None:
|
||||
node = _node_resp(
|
||||
403, {"error": "cannot attach a workstream to a private project you don't belong to"}
|
||||
)
|
||||
with _console_client(node) as client:
|
||||
resp = _create(client)
|
||||
assert resp.status_code == 502
|
||||
assert "private project" not in resp.text
|
||||
|
||||
def test_500_masked(self) -> None:
|
||||
with _console_client(_node_resp(500, {"error": "boom"})) as client:
|
||||
resp = _create(client)
|
||||
assert resp.status_code == 502
|
||||
|
||||
def test_non_json_2xx_masked(self) -> None:
|
||||
# R7: a 2xx with no JSON body must mask to 502, not crash the console.
|
||||
with _console_client(_node_resp(200, content=b"<html>not json</html>")) as client:
|
||||
resp = _create(client)
|
||||
assert resp.status_code == 502
|
||||
|
||||
def test_network_error_masked(self) -> None:
|
||||
with _console_client(raise_exc=httpx.ConnectError("boom")) as client:
|
||||
resp = _create(client)
|
||||
assert resp.status_code == 502
|
||||
|
||||
def test_success_200_regression(self) -> None:
|
||||
with _console_client(_node_resp(200, {"ws_id": "ws_new", "name": "x"})) as client:
|
||||
resp = _create(client)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["correlation_id"] == "ws_new"
|
||||
assert data["target_node"] == "node-a"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node gate kind-scoping — drives the REAL make_create_handler for BOTH kinds.
|
||||
# The handler emits `code: "require_project"` ONLY at the gate, so that marker's
|
||||
# presence/absence in the response is an exact witness of whether the gate fired
|
||||
# — closing the load-bearing `cfg.list_kind == "interactive"` guard end-to-end.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gate_request(body: dict[str, Any], cs: Any, auth: Any) -> Any:
|
||||
"""A minimal Starlette Request: JSON body + auth_result on request.state +
|
||||
config_store on request.app.state — enough to reach the require_project gate."""
|
||||
from starlette.requests import Request
|
||||
|
||||
payload = json.dumps(body).encode()
|
||||
delivered = {"done": False}
|
||||
|
||||
async def _receive() -> dict[str, Any]:
|
||||
if delivered["done"]:
|
||||
return {"type": "http.disconnect"}
|
||||
delivered["done"] = True
|
||||
return {"type": "http.request", "body": payload, "more_body": False}
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/v1/api/workstreams/new",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"query_string": b"",
|
||||
"app": SimpleNamespace(state=SimpleNamespace(config_store=cs)),
|
||||
"state": {"auth_result": auth},
|
||||
}
|
||||
return Request(scope, _receive)
|
||||
|
||||
|
||||
def _is_require_project_400(resp: Any) -> bool:
|
||||
if resp.status_code != 400:
|
||||
return False
|
||||
try:
|
||||
return json.loads(resp.body).get("code") == "require_project"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _run_gate(list_kind: Any, flag_on: bool, body: dict[str, Any], make_config_store: Any) -> Any:
|
||||
"""Drive make_create_handler.create() to the require_project gate for one
|
||||
(kind, flag, body). A gate pass-through raises out of a build_kwargs stub that
|
||||
the handler's own try/except turns into a non-require_project response."""
|
||||
from turnstone.core.session_routes import SessionEndpointConfig, make_create_handler
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
def _build(*_a: Any, **_k: Any) -> dict[str, Any]:
|
||||
raise RuntimeError("stopped just past the require_project gate")
|
||||
|
||||
mgr = MagicMock()
|
||||
mgr.kind = list_kind
|
||||
cfg = SessionEndpointConfig(
|
||||
permission_gate=lambda _req: None,
|
||||
manager_lookup=lambda _req: (mgr, None),
|
||||
tenant_check=None,
|
||||
not_found_label="workstream",
|
||||
audit_action_prefix="ws",
|
||||
list_kind=list_kind,
|
||||
# Derived per-kind HERE the way the real mounts wire it (interactive True,
|
||||
# coordinator False); production keeps it a declarative field, not a kind check.
|
||||
create_gate_require_project=(list_kind == WorkstreamKind.INTERACTIVE),
|
||||
create_validate_request=None,
|
||||
create_build_kwargs=_build,
|
||||
create_supports_attachments=False,
|
||||
create_supports_user_id_override=False,
|
||||
)
|
||||
handler = make_create_handler(cfg)
|
||||
cs = make_config_store(**({"server.require_project": True} if flag_on else {}))
|
||||
auth = _Auth(scopes=("read", "write"), token_source="jwt")
|
||||
return asyncio.run(handler(_gate_request(body, cs, auth)))
|
||||
|
||||
|
||||
class TestNodeGateKindScoping:
|
||||
def test_interactive_projectless_gated(self, make_config_store: Any, tmp_db: Any) -> None:
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
resp = _run_gate(WorkstreamKind.INTERACTIVE, True, {}, make_config_store)
|
||||
assert _is_require_project_400(resp)
|
||||
|
||||
def test_interactive_with_project_passes(self, make_config_store: Any, tmp_db: Any) -> None:
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
resp = _run_gate(WorkstreamKind.INTERACTIVE, True, {"project_id": "p1"}, make_config_store)
|
||||
assert not _is_require_project_400(resp)
|
||||
|
||||
def test_interactive_flag_off_passes(self, make_config_store: Any, tmp_db: Any) -> None:
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
resp = _run_gate(WorkstreamKind.INTERACTIVE, False, {}, make_config_store)
|
||||
assert not _is_require_project_400(resp)
|
||||
|
||||
def test_coordinator_projectless_exempt(self, make_config_store: Any, tmp_db: Any) -> None:
|
||||
# The coordinator mount leaves create_gate_require_project False, so a
|
||||
# projectless coordinator create is NOT gated even with the flag on.
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
resp = _run_gate(WorkstreamKind.COORDINATOR, True, {}, make_config_store)
|
||||
assert not _is_require_project_400(resp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_projects advisory field — the frontend composer reads data.require_project
|
||||
# into requireProject(); pin that the endpoint actually emits it (and reflects
|
||||
# the flag), so a refactor can't silently drop it and fail the picker open.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _list_projects_request(cs: Any, auth: Any) -> Any:
|
||||
from starlette.requests import Request
|
||||
|
||||
async def _receive() -> dict[str, Any]:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/v1/api/projects",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"app": SimpleNamespace(state=SimpleNamespace(config_store=cs)),
|
||||
"state": {"auth_result": auth},
|
||||
}
|
||||
return Request(scope, _receive)
|
||||
|
||||
|
||||
class TestListProjectsAdvisory:
|
||||
def test_require_project_field_on(self, make_config_store: Any, tmp_db: Any) -> None:
|
||||
import turnstone.server as server_mod
|
||||
|
||||
cs = make_config_store(**{"server.require_project": True})
|
||||
auth = _Auth(scopes=("service",)) # service scope bypasses require_permission
|
||||
resp = asyncio.run(server_mod.list_projects(_list_projects_request(cs, auth)))
|
||||
data = json.loads(resp.body)
|
||||
assert data["require_project"] is True
|
||||
assert "projects" in data
|
||||
|
||||
def test_require_project_field_off_by_default(
|
||||
self, make_config_store: Any, tmp_db: Any
|
||||
) -> None:
|
||||
import turnstone.server as server_mod
|
||||
|
||||
cs = make_config_store()
|
||||
auth = _Auth(scopes=("service",))
|
||||
resp = asyncio.run(server_mod.list_projects(_list_projects_request(cs, auth)))
|
||||
assert json.loads(resp.body)["require_project"] is False
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
@@ -2468,3 +2468,32 @@ class TestCompactCommandDispatch:
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["status"] == "busy"
|
||||
assert ws.session.commands == []
|
||||
|
||||
|
||||
class TestRequireProjectMountWiring:
|
||||
"""server.require_project is wired on the REAL interactive create mount
|
||||
(create_gate_require_project=True on interactive_endpoint_config). Synthetic-
|
||||
cfg unit tests can't catch a mis-wire on the actual mount, so drive the
|
||||
mounted endpoint end-to-end."""
|
||||
|
||||
def test_projectless_interactive_create_gated_when_on(self, app_client, make_config_store):
|
||||
client, _mgr = app_client
|
||||
client.app.state.config_store = make_config_store(**{"server.require_project": True})
|
||||
resp = client.post(
|
||||
"/v1/api/workstreams/new",
|
||||
json={"name": "no-project"},
|
||||
headers=_auth("user-1", permissions=frozenset({"workstreams.create"})),
|
||||
)
|
||||
assert resp.status_code == 400, resp.json()
|
||||
assert resp.json().get("code") == "require_project"
|
||||
|
||||
def test_projectless_interactive_create_allowed_when_off(self, app_client, make_config_store):
|
||||
client, _mgr = app_client
|
||||
client.app.state.config_store = make_config_store() # flag off (default)
|
||||
resp = client.post(
|
||||
"/v1/api/workstreams/new",
|
||||
json={"name": "no-project"},
|
||||
headers=_auth("user-1", permissions=frozenset({"workstreams.create"})),
|
||||
)
|
||||
body = resp.json()
|
||||
assert not (resp.status_code == 400 and body.get("code") == "require_project"), body
|
||||
|
||||
@@ -652,6 +652,13 @@ def _bounded_body_preview(text: str | bytes, cap: int = 200) -> str:
|
||||
return _CONTROL_CHAR_RE.sub(" ", text)[:cap]
|
||||
|
||||
|
||||
def _dispatch_failed(node_id: str) -> JSONResponse:
|
||||
"""The sanitized 502 the cluster-create proxy returns for every masked node
|
||||
outcome (network failure, unparseable 2xx body, and any non-require_project
|
||||
node status). One wording, one status — each caller keeps its own distinct log."""
|
||||
return JSONResponse({"error": f"Dispatch to node {node_id} failed"}, status_code=502)
|
||||
|
||||
|
||||
def _proxy_auth_headers(request: Request) -> dict[str, str]:
|
||||
"""Build auth headers for proxied requests to upstream servers.
|
||||
|
||||
@@ -2205,18 +2212,81 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
)
|
||||
else:
|
||||
resp = await client.post(node_url, json=ws_body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
log.warning("Workstream dispatch to %s failed: %s", node_id, exc)
|
||||
return JSONResponse({"error": f"Dispatch to node {node_id} failed"}, status_code=502)
|
||||
return _dispatch_failed(node_id)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok",
|
||||
"correlation_id": resp.json().get("ws_id", ""),
|
||||
"target_node": node_id,
|
||||
}
|
||||
# The node is the authoritative require_project gate. Surface ONLY its
|
||||
# coded require_project 400 to the operator; mask every OTHER node outcome
|
||||
# as an opaque 502. Rationale (do not "simplify" by re-emitting the node
|
||||
# status/body generically):
|
||||
# * a node 401 re-emitted here trips authFetch's reactive refresh +
|
||||
# force-logout, dumping a VALID operator to the login overlay and
|
||||
# double-sending the (non-idempotent) create;
|
||||
# * a node 429 re-emitted here trips authFetch's auto-retry, minting a
|
||||
# DUPLICATE workstream;
|
||||
# * any non-require_project node body may carry internal detail
|
||||
# ("cannot fork <id>: <persona ValueError>", skill/persona/path text)
|
||||
# that would land in the operator's browser DOM.
|
||||
# The discriminator is an EXACT code match: coded-but-different bodies
|
||||
# (too_many/too_large/upload) and every un-coded 400 mask to 502. The
|
||||
# actionable attach-denied 403 and factory-misconfig 503 are also masked
|
||||
# to 502 — a deliberate leak boundary; do NOT surface 403/503 here without
|
||||
# re-checking the leak. Branch on the explicit success range (not
|
||||
# raise_for_status) so a future httpx that lets a 3xx through still cannot
|
||||
# reach the 2xx json parse. Guard BOTH body reads (a 204 / non-JSON 2xx
|
||||
# would otherwise raise a JSONDecodeError → console 500 → operator retry →
|
||||
# orphan/duplicate ws).
|
||||
try:
|
||||
_raw = resp.json()
|
||||
node_body = _raw if isinstance(_raw, dict) else None
|
||||
except Exception:
|
||||
# Broad by design at this console->node proxy boundary: ANY parse failure
|
||||
# (a 204 with no body, an intermediary's HTML, a truncated body) must fall
|
||||
# through to the 502 mask below, never propagate. Deliberately wider than
|
||||
# the codebase's narrow (ValueError, JSONDecodeError) json-guard.
|
||||
node_body = None
|
||||
|
||||
if 200 <= resp.status_code < 300:
|
||||
if node_body is None:
|
||||
log.warning(
|
||||
"Workstream dispatch to %s: unparseable %s success body: %s",
|
||||
node_id,
|
||||
resp.status_code,
|
||||
_bounded_body_preview(resp.content),
|
||||
)
|
||||
return _dispatch_failed(node_id)
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok",
|
||||
"correlation_id": node_body.get("ws_id", ""),
|
||||
"target_node": node_id,
|
||||
}
|
||||
)
|
||||
|
||||
from turnstone.core.auth import REQUIRE_PROJECT_CODE, REQUIRE_PROJECT_ERROR
|
||||
|
||||
if (
|
||||
resp.status_code == 400
|
||||
and node_body is not None
|
||||
and node_body.get("code") == REQUIRE_PROJECT_CODE
|
||||
):
|
||||
# Surface OUR canonical wording, not the node's echoed `error` string: a
|
||||
# version-skewed or misbehaving node must not control the operator-facing
|
||||
# text, and this keeps the message identical across every refusal cause.
|
||||
log.info("Workstream dispatch to %s refused: require_project", node_id)
|
||||
return JSONResponse(
|
||||
{"error": REQUIRE_PROJECT_ERROR, "code": REQUIRE_PROJECT_CODE},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
log.warning(
|
||||
"Workstream dispatch to %s failed: node status %s: %s",
|
||||
node_id,
|
||||
resp.status_code,
|
||||
_bounded_body_preview(resp.content),
|
||||
)
|
||||
return _dispatch_failed(node_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1529,6 +1529,13 @@ function _refreshAndPopulateProjects() {
|
||||
// Populate the launcher's Project picker from the shared cache, preserving the
|
||||
// current pick across the rebuild (same reason as _populateLauncherNodes) and
|
||||
// always appending the "+ New project…" sentinel after the live list.
|
||||
//
|
||||
// Under server.require_project the console launcher intentionally does NOT gate
|
||||
// this picker on TurnstoneProjects.requireProject() (unlike the node new-ws
|
||||
// dialog): the node create endpoint is the authoritative gate, and its refusal
|
||||
// is surfaced to the operator by the console proxy (create_workstream). A
|
||||
// client-side required-project picker here is a deferred UX nicety, not a
|
||||
// correctness requirement.
|
||||
function _populateHomeProjectDropdown() {
|
||||
if (!_homeCoordComposer) return;
|
||||
const TP = window.TurnstoneProjects;
|
||||
|
||||
@@ -469,6 +469,61 @@ def ensure_project_attachable(
|
||||
return (403, "project access could not be verified")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# server.require_project — opt-in, default-off gate refusing projectless
|
||||
# interactive creates. One predicate is the ONLY flag read (gate + advisory
|
||||
# both call it) so authoritative and advisory logic cannot drift.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Discriminator on the node's 400 body. The console cluster-create proxy
|
||||
# surfaces ONLY this coded 400 to the operator and masks every other node
|
||||
# outcome to a sanitized 502, so this string is a shared contract — import it
|
||||
# on both sides rather than re-spelling the literal.
|
||||
REQUIRE_PROJECT_CODE = "require_project"
|
||||
|
||||
# Operator-facing 400 message. Deliberately generic: a projectless, private,
|
||||
# dangling, or nonexistent fork source must all yield this IDENTICAL text, or
|
||||
# the message itself becomes a cross-tenant oracle.
|
||||
REQUIRE_PROJECT_ERROR = (
|
||||
"This deployment requires every new chat to be filed under a project. "
|
||||
"Choose a project and try again."
|
||||
)
|
||||
|
||||
|
||||
def require_project_enabled(config_store: Any) -> bool:
|
||||
"""Return True iff the ``server.require_project`` gate is switched on.
|
||||
|
||||
The SINGLE flag read, shared by the authoritative create gate and the
|
||||
advisory ``list_projects`` field so the two cannot diverge in logic.
|
||||
Fail-open: a missing config store (storage unwired) reads as off. The
|
||||
ConfigStore returns the registered SettingDef default (``False``) on a
|
||||
cache miss — so an unset flag is off, not ``None`` — but only because the
|
||||
SettingDef IS registered; forgetting it silently disables the feature.
|
||||
"""
|
||||
return config_store is not None and bool(config_store.get("server.require_project"))
|
||||
|
||||
|
||||
def require_project_denies_create(config_store: Any, auth: Any, project_id: Any) -> bool:
|
||||
"""Return True to REFUSE an interactive create under ``server.require_project``.
|
||||
|
||||
Refuses only when the gate is on, the caller is not exempt automation, and
|
||||
no project is attached (whitespace-only counts as none). Exempt identities:
|
||||
the ``service`` scope (channel gateway, scheduler) and coordinator SESSION
|
||||
spawns (``token_source == "coordinator"``). NOT exempt on any admin
|
||||
permission — ``admin.coordinator`` is a human-operator permission and would
|
||||
leak every operator through the gate. ``console-proxy`` (the normal proxied
|
||||
human) is deliberately NOT exempt: it carries the human's own scopes, which
|
||||
never include ``service``.
|
||||
"""
|
||||
if not require_project_enabled(config_store):
|
||||
return False
|
||||
if auth is not None and auth.has_scope("service"):
|
||||
return False
|
||||
if getattr(auth, "token_source", "") == "coordinator":
|
||||
return False
|
||||
return not str(project_id or "").strip()
|
||||
|
||||
|
||||
def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
|
||||
"""Derive legacy scopes from a granular permission set."""
|
||||
scopes: set[str] = set()
|
||||
|
||||
@@ -428,6 +428,12 @@ class SessionEndpointConfig:
|
||||
# wires ``False`` — coord create runs only on the console process
|
||||
# and the operator's auth result is the source of truth.
|
||||
create_supports_user_id_override: bool = False
|
||||
# When ``True`` the shared create handler applies the ``server.require_project``
|
||||
# gate (refuse a projectless interactive create). Declarative per-mount
|
||||
# capability — interactive wires ``True``; coordinator leaves it ``False`` so
|
||||
# coordinator spawns are never gated. A cfg flag rather than a hardcoded kind
|
||||
# literal, matching the ``create_supports_*`` idiom.
|
||||
create_gate_require_project: bool = False
|
||||
# (request, body, uid, uploaded_files) -> JSONResponse | None.
|
||||
# Per-kind pre-create gate (ws_id format, parent ownership, kind
|
||||
# validation, etc. on interactive; 401-on-empty-uid on coord).
|
||||
@@ -2564,6 +2570,29 @@ def make_create_handler(
|
||||
if err_validate is not None:
|
||||
return err_validate
|
||||
|
||||
# --- require_project gate (interactive-create mounts only) -------
|
||||
# Gated by the declarative cfg.create_gate_require_project capability
|
||||
# (True on the interactive create mount, default-False on coordinator)
|
||||
# rather than a hardcoded kind literal, matching the create_supports_*
|
||||
# idiom — coordinator spawns are exempt by design. By this point the
|
||||
# validator has applied any parent-/resume-inherited project_id into body
|
||||
# AND (for a fork) discarded any explicit pick to the source's project or
|
||||
# "", so a private/dangling/projectless fork SOURCE funnels to the SAME
|
||||
# uniform 400 as a projectless fresh create.
|
||||
if cfg.create_gate_require_project:
|
||||
from turnstone.core.auth import (
|
||||
REQUIRE_PROJECT_CODE,
|
||||
REQUIRE_PROJECT_ERROR,
|
||||
require_project_denies_create,
|
||||
)
|
||||
|
||||
_config_store = getattr(request.app.state, "config_store", None)
|
||||
if require_project_denies_create(_config_store, auth, body.get("project_id")):
|
||||
return JSONResponse(
|
||||
{"error": REQUIRE_PROJECT_ERROR, "code": REQUIRE_PROJECT_CODE},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# --- Skill resolution --------------------------------------------
|
||||
# Both kinds resolve a body ``skill`` field through
|
||||
# ``get_skill_by_name`` to the skill_data dict + the next
|
||||
|
||||
@@ -358,6 +358,23 @@ def _build_registry() -> dict[str, SettingDef]:
|
||||
"When the limit is reached, the oldest idle workstream is evicted to make room. "
|
||||
"Each workstream uses memory proportional to its conversation history.",
|
||||
),
|
||||
SettingDef(
|
||||
"server.require_project",
|
||||
"bool",
|
||||
False,
|
||||
"Require new interactive chats to be filed under a project",
|
||||
"server",
|
||||
help="When on, starting a new interactive chat is refused unless it is filed "
|
||||
"under a project. This is off by default and changes nothing until you turn it "
|
||||
"on. A person can start a chat only once they belong to at least one project: "
|
||||
"there is no automatic or personal project, so before turning this on in a "
|
||||
"strict deployment, give each user membership in a project or mark a project "
|
||||
"public, or they will not be able to start chats at all. Forking or resuming a "
|
||||
"chat keeps the source chat's project; forking a chat that has no project is "
|
||||
"refused just like starting a fresh chat without one. Automation such as "
|
||||
"channel and scheduler activity, and coordinator-spawned sessions, are not "
|
||||
"affected.",
|
||||
),
|
||||
# -- cluster --------------------------------------------------------
|
||||
SettingDef(
|
||||
"cluster.node_fan_out_limit",
|
||||
|
||||
+81
-3
@@ -2216,6 +2216,7 @@ async def _interactive_create_validate_request(
|
||||
status_code=400,
|
||||
)
|
||||
inherited_pid = False
|
||||
resume_inherited_pid = False
|
||||
body_parent = body.get("parent_ws_id") or None
|
||||
if body_parent is not None:
|
||||
from turnstone.core.storage._registry import get_storage as _get_storage_for_parent
|
||||
@@ -2243,6 +2244,60 @@ async def _interactive_create_validate_request(
|
||||
if not (body.get("project_id") or "") and parent_row.get("project_id"):
|
||||
body["project_id"] = parent_row.get("project_id")
|
||||
inherited_pid = True
|
||||
# Fork/resume: a fork's project is STRUCTURALLY its source's, and only when
|
||||
# the require_project gate is on (off is byte-identical — no resolve, no
|
||||
# inherit, explicit project_id untouched). This DELIBERATELY DIVERGES from the
|
||||
# sibling parent-coordinator block at :2244: that block DEFERS to an explicit
|
||||
# body project_id (a coordinator spawn carries no history, so the
|
||||
# spawn_workstream(project=…) escape hatch is legitimate), whereas a fork
|
||||
# copies the SOURCE's conversation — which must never be re-filed under an
|
||||
# unrelated project the caller merely owns — so here we DISCARD any explicit
|
||||
# project_id up front and then inherit only the source's own project. An
|
||||
# explicit body project_id can never influence a fork.
|
||||
#
|
||||
# No cross-tenant oracle, by construction: for a source that is inaccessible-
|
||||
# private (attach 403 → resume_inherited_pid drop below), projectless, or
|
||||
# nonexistent/unresolvable, body["project_id"] stays "" and the gate emits ONE
|
||||
# uniform 400 — identical BODY *and* STATUS (the discard, not body-equalising,
|
||||
# is what makes them indistinguishable). The residual side-channel is DB-query
|
||||
# LATENCY only (an inaccessible-private source runs extra get_project/
|
||||
# is_project_member); constant-time storage is out of scope.
|
||||
#
|
||||
# RAW/raising storage is deliberate — the swallowing memory.resolve_workstream
|
||||
# would turn a transient DB error into None → a misleading "requires a project"
|
||||
# 400, whereas a raise here surfaces an honest 500 (consistent with the parent
|
||||
# block's sync get_workstream). resume_ws is only read, never mutated:
|
||||
# post_install still performs the real fork (one extra indexed lookup on the
|
||||
# rare fork path). Caveat (pre-existing to the resume mechanic): post_install
|
||||
# re-resolves via the swallowing resolver, so a delete/blip between here and
|
||||
# there degrades the fork to a fresh chat in the inherited project.
|
||||
from turnstone.core.auth import require_project_enabled
|
||||
|
||||
if (
|
||||
isinstance(resume_ws_id, str)
|
||||
and resume_ws_id
|
||||
and require_project_enabled(getattr(request.app.state, "config_store", None))
|
||||
):
|
||||
# Discard any caller-supplied project_id FIRST: a fork is filed under its
|
||||
# SOURCE's project, or (no accessible source project) refused — never a
|
||||
# caller pick. This structural discard is what blocks the re-file and makes
|
||||
# the {inaccessible/projectless/nonexistent}-source outcomes uniform. It
|
||||
# gates on require_project_enabled (the flag) and NOT require_project_denies_
|
||||
# create (flag + service/coordinator exemptions), DELIBERATELY: the
|
||||
# exemptions waive the "must have a project" MANDATE, but fork-integrity — a
|
||||
# fork's copied history must never be re-filed under an unrelated project —
|
||||
# is a security invariant that binds every forker while the feature is on.
|
||||
# The two require_project predicates differ here on purpose.
|
||||
body["project_id"] = ""
|
||||
from turnstone.core.storage._registry import get_storage as _get_storage_for_resume
|
||||
|
||||
_rstorage = _get_storage_for_resume()
|
||||
if _rstorage is not None:
|
||||
_canonical = _rstorage.resolve_workstream(resume_ws_id)
|
||||
_src_row = _rstorage.get_workstream(_canonical) if _canonical else None
|
||||
if _src_row and _src_row.get("project_id"):
|
||||
body["project_id"] = _src_row["project_id"]
|
||||
resume_inherited_pid = True
|
||||
# Project attach gate (explicit or parent-inherited): a private
|
||||
# project accepts new workstreams only from its owner/members, and a
|
||||
# nonexistent EXPLICIT project_id is a caller error rather than a
|
||||
@@ -2260,7 +2315,19 @@ async def _interactive_create_validate_request(
|
||||
denied = ensure_project_attachable(uid, attach_pid)
|
||||
if denied is not None:
|
||||
status, message = denied
|
||||
if inherited_pid and status == 400:
|
||||
if resume_inherited_pid:
|
||||
# Resume-inherited project: ANY denial (unknown 400, private
|
||||
# 403, storage-blip/None 403) drops to a projectless create, so
|
||||
# a private/inaccessible/dangling SOURCE is indistinguishable
|
||||
# from a projectless or nonexistent one. Surfacing the 403 would
|
||||
# leak that the resume_ws id sits under a private project the
|
||||
# caller can't see (a cross-tenant oracle). The require_project
|
||||
# gate then emits ONE uniform 400 downstream.
|
||||
body["project_id"] = ""
|
||||
elif inherited_pid and status == 400:
|
||||
# Parent-inherited dangling project (deleted): the child simply
|
||||
# isn't attached. A 403 here (revoked coordinator membership)
|
||||
# still hard-returns below — deliberately loud, unlike resume.
|
||||
body["project_id"] = ""
|
||||
else:
|
||||
return JSONResponse({"error": message}, status_code=status)
|
||||
@@ -3110,7 +3177,7 @@ def _project_request_uid(request: Request) -> tuple[str, JSONResponse | None]:
|
||||
|
||||
async def list_projects(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/projects — projects the caller owns, is a member of, or public."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.auth import require_permission, require_project_enabled
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
err = require_permission(request, "project.read")
|
||||
@@ -3126,7 +3193,17 @@ async def list_projects(request: Request) -> JSONResponse:
|
||||
)
|
||||
storage = get_storage()
|
||||
rows = storage.list_projects_for_user(uid, include_archived=include_archived) if storage else []
|
||||
return JSONResponse({"projects": [_project_view(r) for r in rows], "total": len(rows)})
|
||||
# Advisory only — the create gate on the enforcing node is authoritative.
|
||||
# Read via the SAME predicate so advisory and gate can't drift in logic
|
||||
# (they still read per-process ConfigStore caches, which converge at rest).
|
||||
cs = getattr(request.app.state, "config_store", None)
|
||||
return JSONResponse(
|
||||
{
|
||||
"projects": [_project_view(r) for r in rows],
|
||||
"total": len(rows),
|
||||
"require_project": require_project_enabled(cs),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def create_project(request: Request) -> JSONResponse:
|
||||
@@ -4667,6 +4744,7 @@ def create_app(
|
||||
sse_executor_lookup=lambda request: request.app.state.sse_executor,
|
||||
create_supports_attachments=True,
|
||||
create_supports_user_id_override=True,
|
||||
create_gate_require_project=True,
|
||||
create_validate_request=_interactive_create_validate_request,
|
||||
create_build_kwargs=_interactive_create_build_kwargs,
|
||||
create_post_install=_interactive_create_post_install,
|
||||
|
||||
@@ -21,6 +21,7 @@ let _loaded = false; // has the first refresh attempt completed (ok or failed)?
|
||||
let _lastError = null; // last failure: HTTP status, 0 for network/parse, null when ok
|
||||
let _inflight = null; // shared pending refresh so concurrent callers coalesce
|
||||
let _fingerprint = null; // last fired map signature, for change-detection
|
||||
let _requireProject = false; // server.require_project advisory; fail-open false
|
||||
const _subs = []; // () => void, fired after each CHANGED refresh
|
||||
|
||||
function _fp(rows) {
|
||||
@@ -79,12 +80,16 @@ export function refreshProjects() {
|
||||
// than blanking it, and record the status so the failure is visible
|
||||
// instead of looking like an empty list.
|
||||
_lastError = r.status;
|
||||
// The require_project advisory fails OPEN: a stale-true value would make
|
||||
// the composer hide options on a transient error, so reset it to false.
|
||||
_requireProject = false;
|
||||
console.warn("projects: GET /v1/api/projects -> " + r.status);
|
||||
return null;
|
||||
})
|
||||
.then(function (data) {
|
||||
if (data) {
|
||||
_lastError = null;
|
||||
_requireProject = !!data.require_project;
|
||||
_setCache(data.projects || []);
|
||||
}
|
||||
return _cache;
|
||||
@@ -94,6 +99,7 @@ export function refreshProjects() {
|
||||
// preserve the last-known cache, never reject (callers chain a bare
|
||||
// .then), and surface the failure.
|
||||
_lastError = 0;
|
||||
_requireProject = false; // fail-open (see the non-OK branch above)
|
||||
console.warn("projects: refresh failed", e);
|
||||
return _cache;
|
||||
})
|
||||
@@ -126,6 +132,15 @@ export function projectsError() {
|
||||
return _lastError;
|
||||
}
|
||||
|
||||
/** Whether this deployment requires new chats to be filed under a project
|
||||
* (server.require_project). ADVISORY only — the create endpoint on the
|
||||
* enforcing node is authoritative. Fails OPEN to false on any refresh failure
|
||||
* so the composer never hides options on a stale-true value; read it only
|
||||
* AFTER {@link refreshProjects} resolves. */
|
||||
export function requireProject() {
|
||||
return _requireProject;
|
||||
}
|
||||
|
||||
/** Display name for a project_id, or "" when unknown (not yet loaded, no
|
||||
* access, or since-removed). */
|
||||
export function projectName(id) {
|
||||
@@ -169,6 +184,7 @@ window.TurnstoneProjects = {
|
||||
getProjects: getProjects,
|
||||
projectsLoaded: projectsLoaded,
|
||||
projectsError: projectsError,
|
||||
requireProject: requireProject,
|
||||
projectName: projectName,
|
||||
projectChoices: projectChoices,
|
||||
onProjectsChange: onProjectsChange,
|
||||
|
||||
+134
-29
@@ -399,15 +399,37 @@ function showNewWsModal(forkFromWsId) {
|
||||
});
|
||||
|
||||
// Project picker — populated from the shared projects cache, refreshed on
|
||||
// open so a project created elsewhere appears. Hidden when forking: a fork
|
||||
// inherits its parent's project.
|
||||
// open. Fresh creates SHOW it; forks HIDE it — a fork's project is its
|
||||
// source's, enforced server-side (an explicit body project_id is discarded for
|
||||
// a fork), so the frontend never sends a project for a fork.
|
||||
//
|
||||
// By design there is NO in-modal project picker for a fork: under
|
||||
// require_project a fork of a PROJECTLESS source is refused by the node with a
|
||||
// 400 and no project field (matches the setting help "forking a chat that has
|
||||
// no project is refused just like starting a fresh chat without one") — the
|
||||
// operator files the source under a project first. Do NOT "restore" a fork
|
||||
// project picker here without re-opening the cross-project re-file hole it caused.
|
||||
const projLabel = document.querySelector('label[for="new-ws-project"]');
|
||||
const projSelect = document.getElementById("new-ws-project");
|
||||
const projHint = projLabel ? projLabel.querySelector(".label-hint") : null;
|
||||
if (projLabel) projLabel.hidden = !!_forkFromWsId;
|
||||
if (projSelect) projSelect.hidden = !!_forkFromWsId;
|
||||
// Seed the hint from the warm cache SYNCHRONOUSLY so a fresh create isn't
|
||||
// mislabeled "optional" for the duration of the (redundant) refresh round-trip
|
||||
// — requireProject() reads a cache the rail warms at startup. The refresh below
|
||||
// re-affirms it for the rare cold-cache open.
|
||||
if (projHint && !_forkFromWsId && window.TurnstoneProjects) {
|
||||
projHint.textContent = window.TurnstoneProjects.requireProject()
|
||||
? "required"
|
||||
: "optional";
|
||||
}
|
||||
if (projSelect && !_forkFromWsId && window.TurnstoneProjects) {
|
||||
window.TurnstoneProjects.refreshProjects().then(function () {
|
||||
_populateProjectSelect(projSelect);
|
||||
const strict = !!window.TurnstoneProjects.requireProject();
|
||||
// Honest label: under require_project a fresh create must resolve to a real
|
||||
// project, so the static "optional" hint must not claim otherwise.
|
||||
if (projHint) projHint.textContent = strict ? "required" : "optional";
|
||||
_populateProjectSelect(projSelect, { requireProject: strict });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,6 +501,9 @@ function _ensureStandaloneProjectCreator(sel) {
|
||||
},
|
||||
onClose: function () {
|
||||
if (sel.value === _PROJECT_NEW) sel.value = "";
|
||||
// Under require_project a fresh picker must not be left blank after a
|
||||
// cancelled "+ New project…" — snap back to a valid project.
|
||||
_reconcileRequiredProjectSelection(sel);
|
||||
},
|
||||
});
|
||||
if (sel.parentNode) sel.parentNode.insertBefore(creator.el, sel.nextSibling);
|
||||
@@ -502,10 +527,7 @@ function _populatePersonaSelect(sel) {
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
const choices = window.TurnstonePersonas.personaChoices("interactive");
|
||||
choices.forEach(function (c) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = c.value;
|
||||
opt.textContent = c.text;
|
||||
sel.appendChild(opt);
|
||||
_appendOption(sel, c.value, c.text, false);
|
||||
});
|
||||
const stillValid = choices.some(function (c) {
|
||||
return c.value === previous;
|
||||
@@ -518,28 +540,90 @@ function _populatePersonaSelect(sel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fill a project <select> from the shared projects cache, preserving its first
|
||||
// <option> (the "No project" placeholder) and the current selection, and append
|
||||
// the "+ New project…" sentinel. No-op when the projects bridge is absent
|
||||
// (project.read denied / still loading).
|
||||
function _populateProjectSelect(sel) {
|
||||
// Add one <option> to a project <select>.
|
||||
function _appendOption(sel, value, text, disabled) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = value;
|
||||
opt.textContent = text;
|
||||
if (disabled) opt.disabled = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
// True when *val* is currently an <option> value on *sel*.
|
||||
function _optionExists(sel, val) {
|
||||
for (let i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].value === val) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fill a project <select> from the shared projects cache. The picker MODE is
|
||||
// passed EXPLICITLY by each caller as {requireProject}; a re-populate from the
|
||||
// inline "+ New project…" creator passes nothing and reuses the mode stamped on
|
||||
// the element at open time. This helper NEVER reads the _forkFromWsId module
|
||||
// global — the dashboard picker shares it. No-op when the projects bridge is
|
||||
// absent (project.read denied / still loading). Forks don't reach this helper:
|
||||
// their picker is hidden and their project is inherited server-side.
|
||||
//
|
||||
// Option 0 (placeholder) by mode:
|
||||
// - gate off: "No project" (value "" -> a projectless create).
|
||||
// - gate on + projects: "Select a project…" (value "") — the user must
|
||||
// consciously pick; we never auto-select a project they
|
||||
// didn't choose (it could silently file the chat under a
|
||||
// shared one).
|
||||
// - gate on + none: a DISABLED "No projects available" notice (value "").
|
||||
function _populateProjectSelect(sel, opts) {
|
||||
if (!sel || !window.TurnstoneProjects) return;
|
||||
const mode = opts || sel._projMode || {};
|
||||
sel._projMode = mode;
|
||||
const strict = !!mode.requireProject;
|
||||
_ensureStandaloneProjectCreator(sel);
|
||||
const previous = sel.value;
|
||||
const placeholder = sel.options.length ? sel.options[0] : null;
|
||||
const choices = window.TurnstoneProjects.projectChoices();
|
||||
sel.replaceChildren();
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
window.TurnstoneProjects.projectChoices().forEach(function (c) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = c.value;
|
||||
opt.textContent = c.text;
|
||||
sel.appendChild(opt);
|
||||
if (!strict) {
|
||||
_appendOption(sel, "", "No project", false);
|
||||
} else if (choices.length === 0) {
|
||||
_appendOption(sel, "", "No projects available", true);
|
||||
} else {
|
||||
// strict + has projects: a "Select a project…" prompt (value "") so the user
|
||||
// must consciously pick — never auto-file under an unchosen (possibly shared)
|
||||
// project. Zero-touch submit sends no project and the gate returns the 400.
|
||||
_appendOption(sel, "", "Select a project…", false);
|
||||
}
|
||||
choices.forEach(function (c) {
|
||||
_appendOption(sel, c.value, c.text, false);
|
||||
});
|
||||
const newOpt = document.createElement("option");
|
||||
newOpt.value = _PROJECT_NEW;
|
||||
newOpt.textContent = "+ New project…";
|
||||
sel.appendChild(newOpt);
|
||||
if (previous && previous !== _PROJECT_NEW) sel.value = previous;
|
||||
_appendOption(sel, _PROJECT_NEW, "+ New project…", false);
|
||||
if (previous && previous !== _PROJECT_NEW && _optionExists(sel, previous)) {
|
||||
sel.value = previous;
|
||||
}
|
||||
_reconcileRequiredProjectSelection(sel);
|
||||
}
|
||||
|
||||
// Under require_project the picker keeps a VALID selection — an already-chosen
|
||||
// real project, else the "Select a project…" prompt (value "", installed by
|
||||
// populate), else (no projects) the disabled "No projects available" notice. It
|
||||
// must NOT auto-select a real project the user didn't choose — that silently
|
||||
// files a required chat under a possibly-shared project. No-op when the gate is
|
||||
// off. Idempotent — only sets the SELECTION, never adds options.
|
||||
function _reconcileRequiredProjectSelection(sel) {
|
||||
if (!sel) return;
|
||||
const mode = sel._projMode || {};
|
||||
if (!mode.requireProject) return;
|
||||
const real = window.TurnstoneProjects
|
||||
? window.TurnstoneProjects.projectChoices()
|
||||
: [];
|
||||
if (real.length === 0) {
|
||||
sel.value = ""; // the disabled "No projects available" notice
|
||||
return;
|
||||
}
|
||||
const isReal = real.some(function (c) {
|
||||
return c.value === sel.value;
|
||||
});
|
||||
// Keep a real pick; otherwise rest on the "Select a project…" prompt (value
|
||||
// ""), never auto-selecting an unchosen project.
|
||||
if (!isReal) sel.value = "";
|
||||
}
|
||||
|
||||
function submitNewWs() {
|
||||
@@ -561,9 +645,14 @@ function submitNewWs() {
|
||||
if (model) body.model = model;
|
||||
if (judge_model) body.judge_model = judge_model;
|
||||
if (skill && !_forkFromWsId) body.skill = skill;
|
||||
// A fork inherits its parent's project (the picker is hidden); only a fresh
|
||||
// create carries an explicit project_id.
|
||||
if (project_id && !_forkFromWsId) body.project_id = project_id;
|
||||
// Only a FRESH create sends a project_id; a fork's project is its source's,
|
||||
// enforced server-side (the picker is hidden for forks, and any explicit pid is
|
||||
// discarded for a fork on the node). No _PROJECT_NEW guard: the select's change
|
||||
// handler resets the sentinel to "" before opening the creator, so it can't
|
||||
// reach submit (the dashboard quick-create path likewise doesn't filter it).
|
||||
if (project_id && !_forkFromWsId) {
|
||||
body.project_id = project_id;
|
||||
}
|
||||
// Persona likewise — a fork resumes the source's stamped persona.
|
||||
if (persona && !_forkFromWsId) body.persona = persona;
|
||||
if (_forkFromWsId) body.resume_ws = _forkFromWsId;
|
||||
@@ -597,7 +686,19 @@ function submitNewWs() {
|
||||
.then(function (data) {
|
||||
window.TurnstoneHatch.setBusy(dlg, false);
|
||||
if (data.error) {
|
||||
_newWsError(data.error);
|
||||
// A projectless-source fork can't satisfy require_project in this modal
|
||||
// (no picker, and a pick would be discarded server-side), so the generic
|
||||
// "choose a project" text points at an impossible remedy — surface the
|
||||
// real one (file the source under a project first) instead.
|
||||
if (_forkFromWsId && data.code === "require_project") {
|
||||
_newWsError(
|
||||
"This chat isn't filed under a project, so it can't be forked while " +
|
||||
"this deployment requires one. File the source chat under a project " +
|
||||
"first, then fork it.",
|
||||
);
|
||||
} else {
|
||||
_newWsError(data.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.ws_id) {
|
||||
@@ -1398,7 +1499,11 @@ function _loadDashboardOptionsLists() {
|
||||
const projSel = document.getElementById("dashboard-project");
|
||||
if (projSel && window.TurnstoneProjects) {
|
||||
window.TurnstoneProjects.refreshProjects().then(function () {
|
||||
_populateProjectSelect(projSel);
|
||||
// Dashboard quick-create is ALWAYS a fresh create (never a fork); pass the
|
||||
// mode explicitly so the shared helper never reads the fork-only global.
|
||||
_populateProjectSelect(projSel, {
|
||||
requireProject: !!window.TurnstoneProjects.requireProject(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user