Files
odysseus/tests/test_research_endpoint_owner_scope.py

158 lines
5.2 KiB
Python
Raw Normal View History

fix(research): owner-scope endpoint resolution POST /api/research/start (require_privilege "can_use_research" — a normal user, not admin) resolves an endpoint two ways and feeds the row's *decrypted* api_key + base_url into research_handler.start_research(llm_endpoint=, llm_headers=): 1. body.endpoint_id -> query(ModelEndpoint).filter(id == endpoint_id, is_enabled == True).first() 2. no endpoint + nothing configured -> query(ModelEndpoint).filter( is_enabled == True).first() Neither was owner-scoped. ModelEndpoint is a per-user resource (core/database.py: non-null owner = private, "the model picker only shows the endpoint to that user"). So a research-privileged user (or a chat-scoped token) could pass another user's PRIVATE endpoint_id — or fall through to their first-enabled row — and run research against that owner's endpoint: spending their API key / quota and reaching whatever internal base_url they configured (SSRF). This is the same multi-tenant owner-scoping class already fixed for companion/models, the /api/v1/chat session gate (#870), and the /api/v1/chat first-enabled fallback (#1045, _first_enabled_endpoint). These two sinks on the research path were missed. Extract `_owned_enabled_endpoint(db, owner, endpoint_id=None)` which scopes via the shared owner_filter helper (own rows + legacy null-owner shared rows), matching webhook_routes._first_enabled_endpoint and session_routes._owned_endpoint. Used for both sinks. A scoped miss on the explicit-id path returns the existing 404 ("Endpoint not found or disabled"), so endpoint existence isn't revealed. A null/empty owner stays a no-op (single-user / legacy mode). Add regression tests pinning both lookups (cross-owner rejected, own-row allowed, legacy shared-row allowed, disabled-skipped, fallback never borrows, null-owner no-op).
2026-06-04 02:19:28 +04:00
"""Owner-scope regression for /api/research/start endpoint resolution.
`research_start()` resolves a CALLER-SUPPLIED `endpoint_id` (and, with nothing
configured, a bare first-enabled fallback) to a `ModelEndpoint` whose *decrypted*
api_key + base_url then drive the research LLM calls
(`start_research(llm_endpoint=, llm_headers=)`). Both lookups must be
owner-scoped — the caller's own rows plus legacy null-owner ("shared") rows —
so a research-privileged user (or a chat-scoped token) can't bind a research run
to ANOTHER user's PRIVATE endpoint and silently spend that owner's API key /
reach whatever internal base_url they configured. Mirrors the
webhook `_first_enabled_endpoint` (#1045) and session `_owned_endpoint` fixes.
"""
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
# The helper resolves `from src.database import ModelEndpoint` at call time.
# Stub the module so we can hand it a fake declarative class whose column
# comparisons return inspectable predicates (the real one is a SQLAlchemy
# class, MagicMock'd to oblivion by conftest). owner_filter stays REAL.
_sd = types.ModuleType("src.database")
_sd.ModelEndpoint = MagicMock()
sys.modules.setdefault("src.database", _sd)
feat: add ChatGPT Subscription provider (#2876) * feat: Add ChatGPT Subscription support and related features - Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI. - Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status. - Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance. - Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints. - Added new slash commands for managing skills, including listing, searching, and invoking skills. - Implemented caching for skill catalog to optimize performance. - Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing. - Refactored existing code to accommodate new features and improve maintainability. * refactor: share provider device-flow setup - reuse one device-flow backend for Copilot and ChatGPT Subscription - add one frontend device-flow helper for Settings and /setup - put GitHub Copilot back into Add Models, now as a dropdown option - make provider selection just select; clicking Add starts sign-in - stop ChatGPT Subscription setup from opening auth tabs automatically - make /setup copilot and /setup chatgpt-subscription work from chat - show ChatGPT Subscription in the /setup suggestions - show the real error message when setup fails - add focused tests for the shared flow and setup UI * feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX Backend: - Resolve runtime bearer for provider-auth endpoints at probe time via a shared _resolve_probe_key() that delegates to resolve_endpoint_runtime, applied across all probe/refresh call sites. - Skip live completion probes and health pings for discovery-only providers (centralized behind _is_discovery_only_provider) — the Codex/Responses API has no such endpoints, so status is derived from cached models. - Never persist the short lived ChatGPT bearer to the plaintext sessions table; proactively clear any stale bearer left by an earlier code path. - Revoke orphaned ProviderAuthSession credentials when the last endpoint backing them is deleted (_delete_orphaned_provider_auth), surfaced via cleared_provider_auth in the delete response. Frontend (admin.js): - Auto-start the device-auth flow on provider selection so the authorization panel (code + Authorize) shows immediately instead of behind a "Sign in" click. - Remove the redundant top button for device auth providers, move retry into the panel via an inline "Try again". - Drop the self-evident hint text and add an execCommand clipboard fallback so Copy works in non-secure (HTTP/LAN) contexts. * fix: harden chatgpt subscription provider * chore: remove PR media from branch * Fix chatgpt subscription recovery and token handling --------- Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
from routes.research_routes import _owned_enabled_endpoint, _resolve_endpoint_runtime # noqa: E402
fix(research): owner-scope endpoint resolution POST /api/research/start (require_privilege "can_use_research" — a normal user, not admin) resolves an endpoint two ways and feeds the row's *decrypted* api_key + base_url into research_handler.start_research(llm_endpoint=, llm_headers=): 1. body.endpoint_id -> query(ModelEndpoint).filter(id == endpoint_id, is_enabled == True).first() 2. no endpoint + nothing configured -> query(ModelEndpoint).filter( is_enabled == True).first() Neither was owner-scoped. ModelEndpoint is a per-user resource (core/database.py: non-null owner = private, "the model picker only shows the endpoint to that user"). So a research-privileged user (or a chat-scoped token) could pass another user's PRIVATE endpoint_id — or fall through to their first-enabled row — and run research against that owner's endpoint: spending their API key / quota and reaching whatever internal base_url they configured (SSRF). This is the same multi-tenant owner-scoping class already fixed for companion/models, the /api/v1/chat session gate (#870), and the /api/v1/chat first-enabled fallback (#1045, _first_enabled_endpoint). These two sinks on the research path were missed. Extract `_owned_enabled_endpoint(db, owner, endpoint_id=None)` which scopes via the shared owner_filter helper (own rows + legacy null-owner shared rows), matching webhook_routes._first_enabled_endpoint and session_routes._owned_endpoint. Used for both sinks. A scoped miss on the explicit-id path returns the existing 404 ("Endpoint not found or disabled"), so endpoint existence isn't revealed. A null/empty owner stays a no-op (single-user / legacy mode). Add regression tests pinning both lookups (cross-owner rejected, own-row allowed, legacy shared-row allowed, disabled-skipped, fallback never borrows, null-owner no-op).
2026-06-04 02:19:28 +04:00
class _Predicate:
def __init__(self, check):
self._check = check
def __call__(self, row):
return self._check(row)
def __or__(self, other):
return _Predicate(lambda row: self(row) or other(row))
class _Column:
def __init__(self, name):
self.name = name
def __eq__(self, value):
return _Predicate(lambda row: getattr(row, self.name) == value)
class _ModelEndpoint:
id = _Column("id")
is_enabled = _Column("is_enabled")
owner = _Column("owner")
class _Query:
def __init__(self, rows):
self._rows = list(rows)
def filter(self, *predicates):
self._rows = [r for r in self._rows if all(p(r) for p in predicates)]
return self
def first(self):
return self._rows[0] if self._rows else None
class _DB:
def __init__(self, rows):
self._rows = rows
def query(self, model):
assert model is _ModelEndpoint
return _Query(self._rows)
def _ep(eid, owner, *, is_enabled=True):
return SimpleNamespace(id=eid, owner=owner, is_enabled=is_enabled, api_key="sk-secret")
def _resolve(rows, owner, endpoint_id=None):
sys.modules["src.database"].ModelEndpoint = _ModelEndpoint
return _owned_enabled_endpoint(_DB(rows), owner, endpoint_id)
# --- explicit endpoint_id (POST /api/research/start, body.endpoint_id) --------
def test_endpoint_id_rejects_another_owners_private_endpoint():
# bob's private endpoint exists, but alice asking for it by id resolves None
# → the route raises 404 ("Endpoint not found or disabled"), never builds
# headers from bob's key.
rows = [_ep("ep-bob", "bob"), _ep("ep-alice", "alice")]
assert _resolve(rows, "alice", "ep-bob") is None
def test_endpoint_id_returns_callers_own_endpoint():
rows = [_ep("ep-bob", "bob"), _ep("ep-alice", "alice")]
ep = _resolve(rows, "alice", "ep-alice")
assert ep is not None and ep.id == "ep-alice"
def test_endpoint_id_allows_legacy_null_owner_shared_row():
rows = [_ep("ep-shared", None)]
ep = _resolve(rows, "alice", "ep-shared")
assert ep is not None and ep.id == "ep-shared"
def test_endpoint_id_skips_disabled_even_when_owned():
rows = [_ep("ep-alice", "alice", is_enabled=False)]
assert _resolve(rows, "alice", "ep-alice") is None
# --- bare first-enabled fallback (no endpoint_id, nothing configured) ---------
def test_fallback_never_picks_another_owners_endpoint():
# bob's private endpoint is first in the table, alice must never borrow it.
rows = [_ep("ep-bob", "bob"), _ep("ep-shared", None)]
ep = _resolve(rows, "alice")
assert ep is not None and ep.id == "ep-shared"
def test_fallback_returns_none_when_only_others_endpoints():
rows = [_ep("ep-bob", "bob"), _ep("ep-carol", "carol")]
assert _resolve(rows, "alice") is None
# --- legacy single-user / unresolved owner: owner_filter no-op ---------------
def test_null_owner_is_legacy_single_user_noop():
rows = [_ep("ep-x", "bob"), _ep("ep-y", "alice")]
ep = _resolve(rows, None, "ep-x")
assert ep is not None and ep.id == "ep-x"
feat: add ChatGPT Subscription provider (#2876) * feat: Add ChatGPT Subscription support and related features - Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI. - Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status. - Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance. - Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints. - Added new slash commands for managing skills, including listing, searching, and invoking skills. - Implemented caching for skill catalog to optimize performance. - Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing. - Refactored existing code to accommodate new features and improve maintainability. * refactor: share provider device-flow setup - reuse one device-flow backend for Copilot and ChatGPT Subscription - add one frontend device-flow helper for Settings and /setup - put GitHub Copilot back into Add Models, now as a dropdown option - make provider selection just select; clicking Add starts sign-in - stop ChatGPT Subscription setup from opening auth tabs automatically - make /setup copilot and /setup chatgpt-subscription work from chat - show ChatGPT Subscription in the /setup suggestions - show the real error message when setup fails - add focused tests for the shared flow and setup UI * feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX Backend: - Resolve runtime bearer for provider-auth endpoints at probe time via a shared _resolve_probe_key() that delegates to resolve_endpoint_runtime, applied across all probe/refresh call sites. - Skip live completion probes and health pings for discovery-only providers (centralized behind _is_discovery_only_provider) — the Codex/Responses API has no such endpoints, so status is derived from cached models. - Never persist the short lived ChatGPT bearer to the plaintext sessions table; proactively clear any stale bearer left by an earlier code path. - Revoke orphaned ProviderAuthSession credentials when the last endpoint backing them is deleted (_delete_orphaned_provider_auth), surfaced via cleared_provider_auth in the delete response. Frontend (admin.js): - Auto-start the device-auth flow on provider selection so the authorization panel (code + Authorize) shows immediately instead of behind a "Sign in" click. - Remove the redundant top button for device auth providers, move retry into the panel via an inline "Try again". - Drop the self-evident hint text and add an execCommand clipboard fallback so Copy works in non-secure (HTTP/LAN) contexts. * fix: harden chatgpt subscription provider * chore: remove PR media from branch * Fix chatgpt subscription recovery and token handling --------- Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
def test_runtime_resolution_uses_provider_auth_for_chatgpt_subscription(monkeypatch):
ep = SimpleNamespace(
id="ep-chatgpt",
owner="alice",
base_url="https://chatgpt.com/backend-api/codex",
api_key=None,
provider_auth_id="auth-1",
cached_models='["gpt-5.5"]',
hidden_models=None,
)
monkeypatch.setattr(
"src.chatgpt_subscription.resolve_runtime_credentials",
lambda auth_id, owner=None: {
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "fresh-access-token",
},
)
url, model, headers = _resolve_endpoint_runtime(ep, owner="alice", model="")
assert url == "https://chatgpt.com/backend-api/codex/responses"
assert model == "gpt-5.5"
assert headers["Authorization"] == "Bearer fresh-access-token"