Files
DocsGPT/tests/api/answer/test_source_access_control.py
Alex 574f96341e refactor: rename the application package to docsgpt
The backend import package is now docsgpt, the name it will carry on PyPI;
application was far too generic to install into anyone's site-packages.
git mv plus a mechanical rewrite of every import, dotted string and path
reference: 734 Python files, the compose files, Dockerfile, workflows, docs,
setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage
config, .gitignore. Behaviour is unchanged.

Kept for one release:
- A top-level application package whose meta-path finder resolves
  application.x.y to the already-imported docsgpt.x.y object, so old imports
  and entry points (celery -A application.app.celery,
  uvicorn application.asgi:asgi_app) keep working with a FutureWarning.
- Celery registers every application.* task name as an alias of its
  docsgpt.* task on start-up, so messages queued by the previous release still
  run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries
  the previous release wrote are left unread instead of firing twice.

The backend image builds from the repository root (docker build -f
docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore
allow-lists docsgpt/ and application/ and keeps caches, local data, .env
files, the sample index files and the Dockerfile out. Compose and the image
workflows point at the new context.
2026-09-07 10:20:43 +01:00

147 lines
5.2 KiB
Python

"""``active_docs`` is client input and must be authorization-gated.
The retriever runs ``WHERE source_id = <id>`` with no owner predicate, so an
unchecked id read another tenant's documents straight into the answer while
``/api/sources/<id>/search`` correctly refused the same id.
The gate must not break the three legitimate ways a caller reaches a source
they do not own: a direct team grant, running an agent someone shared with
them, and calling that agent with its API key. The first goes through
``can_access``; the other two resolve from agent config and deliberately never
reach this gate — transitive access *through* a shared agent is a separate
run-time concept (see ``can_access``'s docstring).
"""
from __future__ import annotations
import pytest
import docsgpt.api.answer.services.stream_processor as sp_mod
StreamProcessor = sp_mod.StreamProcessor
OWNED = "src-owned"
TEAM_SHARED = "src-team-shared"
FOREIGN = "src-foreign"
@pytest.fixture(autouse=True)
def _stub_db(monkeypatch):
"""``_load_request_sources`` opens a connection; it needn't be a real one."""
import contextlib
from unittest.mock import MagicMock
@contextlib.contextmanager
def _conn():
yield MagicMock()
# Patch the name bound in stream_processor, not the source module: it is
# imported at module load, so rebinding the origin has no effect.
monkeypatch.setattr(sp_mod, "db_readonly", _conn)
@pytest.fixture
def access_model(monkeypatch):
"""``can_access`` semantics: own it, or hold a direct team grant."""
def _can_access(conn, resource_type, resource_id, user_id):
if user_id == "owner":
return resource_id in (OWNED, TEAM_SHARED)
if user_id == "teammate":
return resource_id == TEAM_SHARED
return False
monkeypatch.setattr(sp_mod, "can_access", _can_access)
def _processor(data, user):
sp = StreamProcessor.__new__(StreamProcessor)
sp.data = data
sp.decoded_token = {"sub": user}
sp.initial_user_id = user
sp._agent_data = data.get("_agent_data")
sp.source = {}
sp.all_sources = []
return sp
@pytest.mark.unit
class TestRequestSourceAccess:
def test_owner_reaches_their_own_source(self, access_model):
sp = _processor({"active_docs": OWNED}, "owner")
sp._configure_source()
assert sp.source == {"active_docs": OWNED}
def test_teammate_reaches_a_team_shared_source(self, access_model):
"""A direct team grant is exactly what ``can_access`` allows."""
sp = _processor({"active_docs": TEAM_SHARED}, "teammate")
sp._configure_source()
assert sp.source == {"active_docs": TEAM_SHARED}
def test_teammate_cannot_reach_an_unshared_source(self, access_model):
sp = _processor({"active_docs": OWNED}, "teammate")
sp._configure_source()
assert sp.source == {}
def test_stranger_cannot_reach_anything(self, access_model):
sp = _processor({"active_docs": FOREIGN}, "stranger")
sp._configure_source()
assert sp.source == {}
def test_list_keeps_only_the_permitted_ids(self, access_model):
sp = _processor({"active_docs": [TEAM_SHARED, OWNED]}, "teammate")
sp._configure_source()
assert sp.source == {"active_docs": [TEAM_SHARED]}
def test_all_sources_matches_the_permitted_set(self, access_model):
sp = _processor({"active_docs": [TEAM_SHARED, OWNED]}, "teammate")
sp._configure_source()
assert [e["id"] for e in sp.all_sources] == [TEAM_SHARED]
@pytest.mark.unit
class TestSharedAgentAccessIsUnaffected:
"""Agent-resolved sources must keep working for a non-owner.
Someone running a shared agent, or calling it with its API key, never
supplied the source id — it comes from the agent's own config, which was
gated when the owner attached it. Re-gating here against the *caller*
would break every shared agent that has a source.
"""
def test_shared_agent_multi_source_survives(self, access_model):
sp = _processor(
{
"_agent_data": {
"user_id": "owner",
"sources": [{"id": OWNED}, {"id": FOREIGN}],
}
},
"teammate",
)
sp._configure_source()
assert sp.source == {"active_docs": [OWNED, FOREIGN]}
assert len(sp.all_sources) == 2
def test_shared_agent_legacy_single_source_survives(self, access_model):
sp = _processor(
{"_agent_data": {"user_id": "owner", "source": OWNED}}, "teammate"
)
sp._configure_source()
assert sp.source == {"active_docs": OWNED}
def test_api_key_caller_reaches_the_agents_source(self, access_model):
"""An API-key call carries no user of its own; the agent owns access."""
sp = _processor(
{"_agent_data": {"user_id": "owner", "source": OWNED}}, None
)
sp._configure_source()
assert sp.source == {"active_docs": OWNED}
def test_agent_default_placeholder_still_means_no_source(self, access_model):
sp = _processor(
{"_agent_data": {"user_id": "owner", "source": "default"}}, "teammate"
)
sp._configure_source()
assert sp.source == {}