Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,235 @@
|
||||
"""Who may point the worker at the server's own filesystem.
|
||||
|
||||
The allowlist (``RAGCORE_SOURCE_ROOTS``, tested next door) answers *which*
|
||||
paths may ever be read. This module answers the separate question of *who* may
|
||||
choose among them. Both gates are required and neither substitutes for the
|
||||
other:
|
||||
|
||||
* a principal without ``FILESYSTEM_SOURCE_MANAGE`` is refused even for a path
|
||||
squarely inside an allowed root;
|
||||
* a principal *with* it is refused for a path outside every root.
|
||||
|
||||
The chosen policy is the smallest one that closes the multi-workspace hole:
|
||||
the allowed roots are a single deployment-wide list, so a path inside them may
|
||||
belong to another tenant's corpus. Until roots are per-workspace, choosing one
|
||||
is a deployment decision, and only ``PLATFORM_ADMIN`` holds the permission.
|
||||
Ordinary content curation (``SOURCE_MANAGE``, held by Editors and Space
|
||||
Managers) is untouched -- only the filesystem-backed kinds are narrowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.sources import SourceManagementService, SourceRegistryError
|
||||
from ragcore.domain.authorization import (
|
||||
ROLE_PERMISSIONS,
|
||||
Permission,
|
||||
PermissionDenied,
|
||||
Principal,
|
||||
PrincipalType,
|
||||
)
|
||||
from ragcore.domain.control import KnowledgeSpace, Role
|
||||
from ragcore.domain.sources import FILESYSTEM_BACKED_KINDS, ConnectorKind
|
||||
|
||||
pytestmark = pytest.mark.security
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
_ALLOWED = (Path("/mnt/rag-sources"),)
|
||||
|
||||
|
||||
def _principal(*roles: Role) -> Principal:
|
||||
return Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset(roles))
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeRegistry:
|
||||
created: list[object] = field(default_factory=list)
|
||||
|
||||
async def create_source(self, record: object) -> object:
|
||||
self.created.append(record)
|
||||
return record
|
||||
|
||||
async def update_source(self, record: object) -> object:
|
||||
self.created.append(record)
|
||||
return record
|
||||
|
||||
async def get_owned_source(
|
||||
self, source_id: UUID, *, workspace_id: UUID, space_id: UUID | None = None
|
||||
) -> object | None:
|
||||
return None
|
||||
|
||||
async def list_failed_items(self, source_id: UUID, *, limit: int = 20) -> tuple[object, ...]:
|
||||
return ()
|
||||
|
||||
|
||||
class FakeControl:
|
||||
async def list_spaces(self, workspace_id: UUID) -> tuple[KnowledgeSpace, ...]:
|
||||
return (
|
||||
KnowledgeSpace(
|
||||
id=SPACE_ID, workspace_id=WORKSPACE_ID, name="Runbooks", slug="runbooks"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FakeDocuments:
|
||||
async def count_documents_by_source(self, source_ids: tuple[UUID, ...]) -> dict[UUID, int]:
|
||||
return {}
|
||||
|
||||
|
||||
def _service() -> tuple[SourceManagementService, FakeRegistry]:
|
||||
registry = FakeRegistry()
|
||||
return (
|
||||
SourceManagementService(
|
||||
registry=registry, # type: ignore[arg-type]
|
||||
authorization=AuthorizationService(),
|
||||
control=FakeControl(), # type: ignore[arg-type]
|
||||
documents=FakeDocuments(), # type: ignore[arg-type]
|
||||
allowed_roots=_ALLOWED,
|
||||
),
|
||||
registry,
|
||||
)
|
||||
|
||||
|
||||
async def _create(principal: Principal, kind: ConnectorKind) -> object:
|
||||
service, registry = _service()
|
||||
values = {
|
||||
"root": "/mnt/rag-sources/runbooks",
|
||||
"repository": "https://example.invalid/repo.git",
|
||||
"ref": "a" * 40,
|
||||
"checkout_root": "/mnt/rag-sources/checkouts",
|
||||
}
|
||||
created = await service.create_source(
|
||||
principal,
|
||||
name="Runbooks",
|
||||
kind=kind,
|
||||
space_id=SPACE_ID,
|
||||
config_values=values,
|
||||
trace_id="trace-fs",
|
||||
)
|
||||
assert registry.created
|
||||
return created
|
||||
|
||||
|
||||
# -- the permission matrix itself -------------------------------------------
|
||||
|
||||
|
||||
def test_only_the_platform_administrator_holds_the_filesystem_permission() -> None:
|
||||
holders = {
|
||||
role
|
||||
for role, permissions in ROLE_PERMISSIONS.items()
|
||||
if Permission.FILESYSTEM_SOURCE_MANAGE in permissions
|
||||
}
|
||||
|
||||
assert holders == {Role.PLATFORM_ADMIN}
|
||||
|
||||
|
||||
def test_ordinary_source_management_is_not_narrowed() -> None:
|
||||
"""Only the sensitive kinds are restricted. Curating content -- including
|
||||
upload sources -- stays exactly as available as it was."""
|
||||
|
||||
for role in (Role.EDITOR, Role.SPACE_MANAGER, Role.WORKSPACE_ADMIN):
|
||||
assert Permission.SOURCE_MANAGE in ROLE_PERMISSIONS[role]
|
||||
|
||||
|
||||
def test_the_filesystem_backed_kinds_are_the_ones_that_read_the_host() -> None:
|
||||
assert frozenset({ConnectorKind.FOLDER, ConnectorKind.GIT}) == FILESYSTEM_BACKED_KINDS
|
||||
assert ConnectorKind.FOLDER.reads_server_filesystem
|
||||
assert ConnectorKind.GIT.reads_server_filesystem
|
||||
assert not ConnectorKind.UPLOAD.reads_server_filesystem
|
||||
|
||||
|
||||
# -- creation ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [Role.EDITOR, Role.SPACE_MANAGER, Role.WORKSPACE_ADMIN])
|
||||
@pytest.mark.parametrize("kind", sorted(FILESYSTEM_BACKED_KINDS))
|
||||
async def test_a_role_without_the_permission_cannot_create_a_filesystem_source(
|
||||
role: Role, kind: ConnectorKind
|
||||
) -> None:
|
||||
"""Refused for a path that is *inside* an allowed root, so the refusal can
|
||||
only be the authorization gate and not the allowlist."""
|
||||
|
||||
with pytest.raises(PermissionDenied):
|
||||
await _create(_principal(role), kind)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", sorted(FILESYSTEM_BACKED_KINDS))
|
||||
async def test_the_platform_administrator_can_create_a_filesystem_source(
|
||||
kind: ConnectorKind,
|
||||
) -> None:
|
||||
created = await _create(_principal(Role.PLATFORM_ADMIN), kind)
|
||||
|
||||
assert created.kind is kind
|
||||
|
||||
|
||||
async def test_a_viewer_cannot_create_any_source_at_all() -> None:
|
||||
from ragcore.domain.authorization import ResourceNotFound
|
||||
|
||||
with pytest.raises((PermissionDenied, ResourceNotFound)):
|
||||
await _create(_principal(Role.VIEWER), ConnectorKind.FOLDER)
|
||||
|
||||
|
||||
# -- authorization does not replace the sandbox -----------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("root", ["/etc", "/app", "/var/lib/postgresql/data", "/"])
|
||||
async def test_the_platform_administrator_is_still_bound_by_the_allowed_roots(
|
||||
root: str,
|
||||
) -> None:
|
||||
"""Role and sandbox are independent gates. Holding the strongest
|
||||
permission in the system does not widen the paths the deployment approved.
|
||||
"""
|
||||
|
||||
service, _ = _service()
|
||||
|
||||
with pytest.raises(SourceRegistryError, match="outside every allowed source root"):
|
||||
await service.create_source(
|
||||
_principal(Role.PLATFORM_ADMIN),
|
||||
name="Escape",
|
||||
kind=ConnectorKind.FOLDER,
|
||||
space_id=SPACE_ID,
|
||||
config_values={"root": root},
|
||||
trace_id="trace-fs",
|
||||
)
|
||||
|
||||
|
||||
async def test_with_no_configured_roots_even_the_platform_administrator_is_refused() -> None:
|
||||
service = SourceManagementService(
|
||||
registry=FakeRegistry(), # type: ignore[arg-type]
|
||||
authorization=AuthorizationService(),
|
||||
control=FakeControl(), # type: ignore[arg-type]
|
||||
documents=FakeDocuments(), # type: ignore[arg-type]
|
||||
allowed_roots=(),
|
||||
)
|
||||
|
||||
with pytest.raises(SourceRegistryError, match="no allowed source roots"):
|
||||
await service.create_source(
|
||||
_principal(Role.PLATFORM_ADMIN),
|
||||
name="Runbooks",
|
||||
kind=ConnectorKind.FOLDER,
|
||||
space_id=SPACE_ID,
|
||||
config_values={"root": "/mnt/rag-sources/runbooks"},
|
||||
trace_id="trace-fs",
|
||||
)
|
||||
|
||||
|
||||
# -- the capability probe the screens use -----------------------------------
|
||||
|
||||
|
||||
def test_can_manage_filesystem_sources_matches_the_permission() -> None:
|
||||
service, _ = _service()
|
||||
|
||||
assert service.can_manage_filesystem_sources(_principal(Role.PLATFORM_ADMIN))
|
||||
for role in (Role.WORKSPACE_ADMIN, Role.SPACE_MANAGER, Role.EDITOR, Role.VIEWER):
|
||||
assert not service.can_manage_filesystem_sources(_principal(role))
|
||||
# ...while ordinary source management is untouched for the roles that
|
||||
# legitimately curate content.
|
||||
assert service.can_manage(_principal(Role.EDITOR))
|
||||
assert service.can_manage(_principal(Role.WORKSPACE_ADMIN))
|
||||
@@ -0,0 +1,235 @@
|
||||
"""The filesystem-source policy, proven at the HTTP boundary.
|
||||
|
||||
The unit-level tests next door prove the service refuses. This one proves the
|
||||
refusal survives the route: the Control Plane form is posted directly, with no
|
||||
button to click and nothing hidden, against the *real*
|
||||
``SourceManagementService`` and the real authorization service. Only the
|
||||
persistence ports are fakes.
|
||||
|
||||
That distinction is the point of the test. Hiding "Add source" from a role is
|
||||
courtesy; the boundary is that posting the form anyway is refused, and that no
|
||||
source row is written when it is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.control.dependencies import get_principal
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.documents import DocumentListPage
|
||||
from ragcore.application.sources import (
|
||||
SourceListPage,
|
||||
SourceManagementService,
|
||||
SourceRecord,
|
||||
SourceStatus,
|
||||
)
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import KnowledgeSpace, Role
|
||||
from ragcore.domain.sources import ConnectorKind
|
||||
from ragcore.main import create_app
|
||||
from ragcore.web.sources import get_source_management
|
||||
|
||||
pytestmark = pytest.mark.security
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
FOLDER_SOURCE_ID = uuid4()
|
||||
_ALLOWED = (Path("/mnt/rag-sources"),)
|
||||
_INSIDE = "/mnt/rag-sources/runbooks"
|
||||
_OUTSIDE = "/etc"
|
||||
|
||||
|
||||
def _principal(role: Role) -> Principal:
|
||||
return Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset({role}))
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeRegistry:
|
||||
sources: dict[UUID, SourceRecord] = field(default_factory=dict)
|
||||
writes: list[SourceRecord] = field(default_factory=list)
|
||||
|
||||
async def create_source(self, record: SourceRecord) -> SourceRecord:
|
||||
self.writes.append(record)
|
||||
self.sources[record.id] = record
|
||||
return record
|
||||
|
||||
async def update_source(self, record: SourceRecord) -> SourceRecord:
|
||||
self.writes.append(record)
|
||||
self.sources[record.id] = record
|
||||
return record
|
||||
|
||||
async def get_owned_source(
|
||||
self, source_id: UUID, *, workspace_id: UUID, space_id: UUID | None = None
|
||||
) -> SourceRecord | None:
|
||||
source = self.sources.get(source_id)
|
||||
if source is None or source.workspace_id != workspace_id:
|
||||
return None
|
||||
return source
|
||||
|
||||
async def list_sources(self, workspace_id: UUID, filters: object) -> SourceListPage:
|
||||
items = tuple(
|
||||
source for source in self.sources.values() if source.workspace_id == workspace_id
|
||||
)
|
||||
return SourceListPage(items=items, total=len(items), limit=50, offset=0)
|
||||
|
||||
async def list_sync_runs(self, source_id: UUID, *, limit: int = 20) -> tuple[object, ...]:
|
||||
return ()
|
||||
|
||||
async def list_failed_items(self, source_id: UUID, *, limit: int = 20) -> tuple[object, ...]:
|
||||
return ()
|
||||
|
||||
async def set_status(
|
||||
self, source_id: UUID, status: SourceStatus, *, now: datetime
|
||||
) -> SourceRecord | None:
|
||||
source = self.sources.get(source_id)
|
||||
if source is None:
|
||||
return None
|
||||
self.sources[source_id] = replace(source, status=status)
|
||||
return self.sources[source_id]
|
||||
|
||||
|
||||
class FakeControl:
|
||||
async def list_spaces(self, workspace_id: UUID) -> tuple[KnowledgeSpace, ...]:
|
||||
return (
|
||||
KnowledgeSpace(
|
||||
id=SPACE_ID, workspace_id=WORKSPACE_ID, name="Runbooks", slug="runbooks"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FakeDocuments:
|
||||
async def count_documents_by_source(self, source_ids: tuple[UUID, ...]) -> dict[UUID, int]:
|
||||
return {}
|
||||
|
||||
async def list_documents(self, workspace_id: UUID, filters: object) -> DocumentListPage:
|
||||
return DocumentListPage(items=(), total=0, limit=25, offset=0)
|
||||
|
||||
|
||||
def _client(role: Role) -> tuple[TestClient, FakeRegistry]:
|
||||
registry = FakeRegistry()
|
||||
registry.sources[FOLDER_SOURCE_ID] = SourceRecord(
|
||||
id=FOLDER_SOURCE_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
name="Runbooks",
|
||||
kind=ConnectorKind.FOLDER,
|
||||
config={"root": _INSIDE, "include": [], "exclude": [], "trust_level": "trusted_internal"},
|
||||
)
|
||||
service = SourceManagementService(
|
||||
registry=registry, # type: ignore[arg-type]
|
||||
authorization=AuthorizationService(),
|
||||
control=FakeControl(), # type: ignore[arg-type]
|
||||
documents=FakeDocuments(), # type: ignore[arg-type]
|
||||
allowed_roots=_ALLOWED,
|
||||
)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_principal] = lambda: _principal(role)
|
||||
app.dependency_overrides[get_source_management] = lambda: service
|
||||
app.state.session_factory = None
|
||||
return TestClient(app), registry
|
||||
|
||||
|
||||
def _form(root: str = _INSIDE) -> dict[str, str]:
|
||||
return {
|
||||
"name": "Runbooks",
|
||||
"kind": "folder",
|
||||
"space_id": str(SPACE_ID),
|
||||
"root": root,
|
||||
"include": "",
|
||||
"exclude": "",
|
||||
"trust_level": "trusted_internal",
|
||||
}
|
||||
|
||||
|
||||
# -- creation ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [Role.EDITOR, Role.SPACE_MANAGER, Role.WORKSPACE_ADMIN])
|
||||
def test_posting_the_create_form_is_refused_without_the_filesystem_permission(
|
||||
role: Role,
|
||||
) -> None:
|
||||
client, registry = _client(role)
|
||||
|
||||
response = client.post("/admin/sources", data=_form(), follow_redirects=False)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert registry.writes == []
|
||||
assert _INSIDE not in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [Role.EDITOR, Role.SPACE_MANAGER, Role.WORKSPACE_ADMIN])
|
||||
def test_the_create_form_itself_is_not_served_without_the_permission(role: Role) -> None:
|
||||
client, _ = _client(role)
|
||||
|
||||
assert client.get("/admin/sources/new").status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [Role.EDITOR, Role.SPACE_MANAGER, Role.WORKSPACE_ADMIN])
|
||||
def test_editing_an_existing_folder_source_is_refused_too(role: Role) -> None:
|
||||
"""Editing is how an approved source gets repointed at a different
|
||||
directory, so it is the same capability as creating one."""
|
||||
|
||||
client, registry = _client(role)
|
||||
|
||||
# 403 for a role that may read the source but not repoint it; 404 for one
|
||||
# that may not see it at all (an Editor has no SPACE_READ). Both are
|
||||
# denials that reveal nothing -- what matters is that neither writes.
|
||||
assert client.get(f"/admin/sources/{FOLDER_SOURCE_ID}/edit").status_code in (403, 404)
|
||||
posted = client.post(
|
||||
f"/admin/sources/{FOLDER_SOURCE_ID}/edit",
|
||||
data={
|
||||
"name": "Runbooks",
|
||||
"root": "/mnt/rag-sources/elsewhere",
|
||||
"trust_level": "unverified",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert posted.status_code in (403, 404)
|
||||
assert registry.writes == []
|
||||
assert registry.sources[FOLDER_SOURCE_ID].config["root"] == _INSIDE
|
||||
|
||||
|
||||
def test_the_platform_administrator_may_create_a_filesystem_source() -> None:
|
||||
client, registry = _client(Role.PLATFORM_ADMIN)
|
||||
|
||||
response = client.post("/admin/sources", data=_form(), follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert [record.kind for record in registry.writes] == [ConnectorKind.FOLDER]
|
||||
assert str(registry.writes[0].config["root"]).replace("\\", "/") == _INSIDE
|
||||
|
||||
|
||||
# -- role permission and path sandbox are both required ---------------------
|
||||
|
||||
|
||||
def test_a_path_outside_the_allowed_roots_is_refused_for_the_platform_administrator() -> None:
|
||||
client, registry = _client(Role.PLATFORM_ADMIN)
|
||||
|
||||
response = client.post("/admin/sources", data=_form(root=_OUTSIDE), follow_redirects=False)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "outside every allowed source root" in response.text
|
||||
assert registry.writes == []
|
||||
|
||||
|
||||
def test_a_hidden_button_is_never_the_boundary() -> None:
|
||||
"""The Sources list omits the Add-source affordance for a role that may
|
||||
not use it -- and posting the form anyway is still refused. The second
|
||||
assertion is the security property; the first is only the UI telling the
|
||||
truth about it."""
|
||||
|
||||
client, registry = _client(Role.WORKSPACE_ADMIN)
|
||||
|
||||
listing = client.get("/admin/sources")
|
||||
assert listing.status_code == 200
|
||||
assert '/admin/sources/new"' not in listing.text
|
||||
|
||||
assert client.post("/admin/sources", data=_form(), follow_redirects=False).status_code == 403
|
||||
assert registry.writes == []
|
||||
@@ -0,0 +1,132 @@
|
||||
"""The allowlist at the two boundaries that surround the connectors.
|
||||
|
||||
The connectors enforce it against the real filesystem before every read; these
|
||||
are the two places that decide a path may be *stored* and *built into a
|
||||
connector* at all. Both must fail closed on their own -- neither is allowed to
|
||||
rely on the other having checked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.jobs import TerminalJobError
|
||||
from ragcore.application.sources import (
|
||||
SourceRecord,
|
||||
SourceRegistryError,
|
||||
SourceStatus,
|
||||
build_source_config,
|
||||
)
|
||||
from ragcore.domain.sources import ConnectorKind
|
||||
from ragcore.infrastructure.ingestion.sync_handler import build_connector
|
||||
|
||||
pytestmark = pytest.mark.security
|
||||
|
||||
_ALLOWED = (Path("/mnt/rag-sources"),)
|
||||
|
||||
|
||||
# -- the Control Plane boundary: what may be stored --------------------------
|
||||
|
||||
|
||||
def test_a_folder_root_inside_an_allowed_root_is_accepted() -> None:
|
||||
config = build_source_config(
|
||||
ConnectorKind.FOLDER, {"root": "/mnt/rag-sources/runbooks"}, allowed_roots=_ALLOWED
|
||||
)
|
||||
|
||||
assert str(config["root"]).replace("\\", "/") == "/mnt/rag-sources/runbooks"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("root", ["/etc", "/app", "/var/lib/postgresql/data"])
|
||||
def test_a_folder_root_outside_every_allowed_root_is_refused(root: str) -> None:
|
||||
with pytest.raises(SourceRegistryError, match="outside every allowed source root"):
|
||||
build_source_config(ConnectorKind.FOLDER, {"root": root}, allowed_roots=_ALLOWED)
|
||||
|
||||
|
||||
def test_no_configured_roots_means_no_folder_source_can_be_created() -> None:
|
||||
"""The default is fail closed: a deployment that never named a root does
|
||||
not get an implicit one, least of all ``/``."""
|
||||
|
||||
with pytest.raises(SourceRegistryError, match="no allowed source roots"):
|
||||
build_source_config(
|
||||
ConnectorKind.FOLDER, {"root": "/mnt/rag-sources/runbooks"}, allowed_roots=()
|
||||
)
|
||||
|
||||
|
||||
def test_a_git_checkout_root_answers_to_the_same_allowlist() -> None:
|
||||
"""The checkout root is an operator-supplied server-side path the worker
|
||||
runs git against -- the same exposure a folder root is."""
|
||||
|
||||
with pytest.raises(SourceRegistryError, match="outside every allowed source root"):
|
||||
build_source_config(
|
||||
ConnectorKind.GIT,
|
||||
{"repository": "local", "ref": "main", "checkout_root": "/app"},
|
||||
allowed_roots=_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
def test_an_upload_source_needs_no_root() -> None:
|
||||
"""Uploads are fed by callers, never read from the server's filesystem, so
|
||||
the allowlist has nothing to say about them."""
|
||||
|
||||
assert build_source_config(ConnectorKind.UPLOAD, {}, allowed_roots=()) == {
|
||||
"origin": "control-plane",
|
||||
"trust_level": "trusted_internal",
|
||||
}
|
||||
|
||||
|
||||
# -- the worker boundary: what may be built into a connector -----------------
|
||||
|
||||
|
||||
def _record(kind: ConnectorKind, config: dict[str, object]) -> SourceRecord:
|
||||
return SourceRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
name="Runbooks",
|
||||
kind=kind,
|
||||
status=SourceStatus.ACTIVE,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def test_a_stored_source_outside_the_allowlist_fails_terminally() -> None:
|
||||
"""A record stored before the allowlist was tightened must not be retried
|
||||
forever: no number of attempts turns a path an operator disallowed into
|
||||
one they allowed."""
|
||||
|
||||
source = _record(ConnectorKind.FOLDER, {"root": "/etc"})
|
||||
|
||||
with pytest.raises(TerminalJobError, match="outside every allowed source root"):
|
||||
build_connector(source, allowed_roots=_ALLOWED)
|
||||
|
||||
|
||||
def test_a_stored_source_inside_the_allowlist_builds_a_connector() -> None:
|
||||
source = _record(ConnectorKind.FOLDER, {"root": "/mnt/rag-sources/runbooks"})
|
||||
|
||||
connector = build_connector(source, allowed_roots=_ALLOWED)
|
||||
|
||||
assert connector.allowed_roots == _ALLOWED # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_the_worker_fails_closed_when_no_roots_are_configured() -> None:
|
||||
source = _record(ConnectorKind.FOLDER, {"root": "/mnt/rag-sources/runbooks"})
|
||||
|
||||
with pytest.raises(TerminalJobError, match="outside every allowed source root"):
|
||||
build_connector(source, allowed_roots=())
|
||||
|
||||
|
||||
def test_a_pattern_filtered_connector_still_carries_the_allowlist() -> None:
|
||||
"""Wrapping must not lose the check: the inner connector is the one that
|
||||
reads, and it is the one that has to hold the roots."""
|
||||
|
||||
source = _record(
|
||||
ConnectorKind.FOLDER,
|
||||
{"root": "/mnt/rag-sources/runbooks", "include": ["*.md"]},
|
||||
)
|
||||
|
||||
connector = build_connector(source, allowed_roots=_ALLOWED)
|
||||
|
||||
assert connector._inner.allowed_roots == _ALLOWED # type: ignore[attr-defined]
|
||||
@@ -0,0 +1,176 @@
|
||||
"""The filesystem allowlist: which server-side paths a source may ever read.
|
||||
|
||||
Rejecting traversal, symlinks and relative paths only proves a path is well
|
||||
formed. These tests are about the separate question the allowlist answers --
|
||||
whether a well-formed path is one an operator ever intended RAGcore to read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.domain.sources import (
|
||||
SourceRootError,
|
||||
assert_within_allowed_roots,
|
||||
normalize_allowed_roots,
|
||||
resolve_within_allowed_roots,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.security
|
||||
|
||||
_ROOTS = (Path("/mnt/rag-sources"), Path("/srv/corpora"))
|
||||
|
||||
|
||||
def test_no_configured_roots_allows_nothing() -> None:
|
||||
"""Fail closed. An empty allowlist is a real answer -- "this deployment
|
||||
reads no server-side paths" -- not a missing one to be defaulted away."""
|
||||
|
||||
with pytest.raises(SourceRootError, match="no allowed source roots"):
|
||||
assert_within_allowed_roots("/mnt/rag-sources/runbooks", ())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"requested",
|
||||
[
|
||||
"/mnt/rag-sources",
|
||||
"/mnt/rag-sources/runbooks",
|
||||
"/mnt/rag-sources/runbooks/fleet",
|
||||
"/srv/corpora/handbook",
|
||||
],
|
||||
)
|
||||
def test_a_root_and_anything_below_it_is_allowed(requested: str) -> None:
|
||||
assert assert_within_allowed_roots(requested, _ROOTS) == Path(requested)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"requested",
|
||||
[
|
||||
"/etc",
|
||||
"/etc/shadow",
|
||||
"/app/src/ragcore/config.py",
|
||||
"/var/lib/postgresql/data",
|
||||
"/mnt",
|
||||
"/",
|
||||
],
|
||||
)
|
||||
def test_a_path_outside_every_root_is_refused(requested: str) -> None:
|
||||
with pytest.raises(SourceRootError, match="outside every allowed source root"):
|
||||
assert_within_allowed_roots(requested, _ROOTS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"requested",
|
||||
[
|
||||
"/mnt/rag-sources-secret",
|
||||
"/mnt/rag-sources-secret/keys",
|
||||
"/srv/corpora-backup",
|
||||
],
|
||||
)
|
||||
def test_a_sibling_sharing_a_string_prefix_is_not_contained(requested: str) -> None:
|
||||
"""Containment is decided on path *components*, never on string prefixes.
|
||||
``/mnt/rag-sources`` does not contain ``/mnt/rag-sources-secret``, however
|
||||
much the two look alike to ``startswith``."""
|
||||
|
||||
with pytest.raises(SourceRootError, match="outside every allowed source root"):
|
||||
assert_within_allowed_roots(requested, _ROOTS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"requested",
|
||||
["relative/path", "", "rag-sources", "C:x"],
|
||||
)
|
||||
def test_a_path_that_is_not_absolute_is_refused(requested: str) -> None:
|
||||
with pytest.raises(SourceRootError):
|
||||
assert_within_allowed_roots(requested, _ROOTS)
|
||||
|
||||
|
||||
def test_traversal_is_refused_before_containment_is_even_considered() -> None:
|
||||
"""``/mnt/rag-sources/../../etc`` normalises to ``/etc``, so a check that
|
||||
normalised first and compared second would be fine either way -- but the
|
||||
path is rejected outright, so a root that happens to be near the top of
|
||||
the tree cannot be walked out of."""
|
||||
|
||||
with pytest.raises(SourceRootError, match="must not traverse"):
|
||||
assert_within_allowed_roots("/mnt/rag-sources/../../etc", _ROOTS)
|
||||
|
||||
|
||||
def test_a_nul_byte_is_refused() -> None:
|
||||
with pytest.raises(SourceRootError, match="NUL-free"):
|
||||
assert_within_allowed_roots("/mnt/rag-sources/x\x00.txt", _ROOTS)
|
||||
|
||||
|
||||
def test_the_stored_spelling_survives_validation() -> None:
|
||||
"""The API host validating a POSIX deployment path must hand back exactly
|
||||
what the operator wrote: a Windows-side ``normpath`` would rewrite it into
|
||||
something the Linux worker could never open."""
|
||||
|
||||
assert str(assert_within_allowed_roots("/mnt/rag-sources/docs", _ROOTS)).replace(
|
||||
"\\", "/"
|
||||
) == "/mnt/rag-sources/docs"
|
||||
|
||||
|
||||
# -- operator-configured roots ----------------------------------------------
|
||||
|
||||
|
||||
def test_a_filesystem_root_may_not_be_an_allowed_root() -> None:
|
||||
"""Allowing ``/`` would make the allowlist meaningless."""
|
||||
|
||||
with pytest.raises(SourceRootError, match="must not be a filesystem root"):
|
||||
normalize_allowed_roots(["/"])
|
||||
|
||||
|
||||
def test_a_relative_or_traversing_root_is_refused_at_startup() -> None:
|
||||
with pytest.raises(SourceRootError, match="absolute"):
|
||||
normalize_allowed_roots(["mnt/rag-sources"])
|
||||
with pytest.raises(SourceRootError, match="traverse"):
|
||||
normalize_allowed_roots(["/mnt/rag-sources/../.."])
|
||||
|
||||
|
||||
def test_duplicate_roots_collapse() -> None:
|
||||
assert normalize_allowed_roots(["/mnt/rag", "/mnt/rag"]) == (Path("/mnt/rag"),)
|
||||
|
||||
|
||||
# -- filesystem truth --------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolution_rejects_a_path_that_does_not_exist(tmp_path: Path) -> None:
|
||||
"""The worker's check is about the filesystem as it is: a lexically fine
|
||||
path that cannot be resolved is not a path it may read."""
|
||||
|
||||
with pytest.raises(SourceRootError, match="cannot be resolved"):
|
||||
resolve_within_allowed_roots(tmp_path / "absent", (tmp_path,))
|
||||
|
||||
|
||||
def test_resolution_accepts_a_real_directory_inside_an_allowed_root(tmp_path: Path) -> None:
|
||||
allowed = tmp_path / "corpora"
|
||||
inside = allowed / "runbooks"
|
||||
inside.mkdir(parents=True)
|
||||
|
||||
assert resolve_within_allowed_roots(inside, (allowed,)) == inside.resolve(strict=True)
|
||||
|
||||
|
||||
def test_resolution_refuses_a_real_directory_outside_every_allowed_root(tmp_path: Path) -> None:
|
||||
allowed = tmp_path / "corpora"
|
||||
allowed.mkdir()
|
||||
elsewhere = tmp_path / "secrets"
|
||||
elsewhere.mkdir()
|
||||
|
||||
with pytest.raises(SourceRootError, match="outside every allowed source root"):
|
||||
resolve_within_allowed_roots(elsewhere, (allowed,))
|
||||
|
||||
|
||||
def test_an_unmounted_root_contains_nothing_but_does_not_break_the_others(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A deployment may configure roots that only some containers mount. One
|
||||
that is absent here simply cannot contain anything; the roots that are
|
||||
present still get their chance."""
|
||||
|
||||
allowed = tmp_path / "corpora"
|
||||
allowed.mkdir()
|
||||
|
||||
assert resolve_within_allowed_roots(
|
||||
allowed, (tmp_path / "never-mounted", allowed)
|
||||
) == allowed.resolve(strict=True)
|
||||
Reference in new issue
Block a user