datasette/tests/test_fts_permissions.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

412 lines
15 KiB
Python
Raw Normal View History

import pytest
from datasette import hookimpl
from datasette.app import Datasette
from datasette.permissions import Action, PermissionSQL, _permission_check_cache
from datasette.resources import DatabaseResource, TableResource
from datasette.utils.sqlite import sqlite3, sqlite_derived_table_dependencies
@pytest.mark.asyncio
@pytest.mark.parametrize("fts_module", ["fts4", "fts5"])
@pytest.mark.parametrize("actor", [None, {"id": "root"}], ids=["anonymous", "root"])
async def test_derived_permissions_allow_one_hop_but_deny_nested_sources(
fts_module, actor
):
class InspectPlugin:
@hookimpl
def register_actions(self):
return [
Action(
name="inspect-derived",
description="Inspect a table",
resource_class=TableResource,
also_requires="view-table",
)
]
@hookimpl
def permission_resources_sql(self, action):
if action == "inspect-derived":
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, 1 AS allow, 'inspect allowed' AS reason"
)
ds = Datasette(memory=True)
ds.pm.register(InspectPlugin(), name="inspect-derived-test")
db = ds.add_memory_database(
f"derived_one_hop_{fts_module}_{actor is not None}", name="data"
)
await db.execute_write("create table Documents (body text)")
await db.execute_write(
f"create virtual table Search using {fts_module}(body, content='Documents')"
)
await db.execute_write(
f"create virtual table Nested using {fts_module}(body, content='sEaRcH')"
)
await ds.invoke_startup()
token = _permission_check_cache.set({})
try:
# Both direct permissions are allowed, but a derived source makes its
# dependent unavailable even to an actor who can view the whole chain.
# Check and cache Search first so its cached grant cannot grant Nested.
for table, expected in (
("Documents", True),
("Search", True),
("Nested", False),
("Search_docsize", False),
):
for spelling in (table, table.upper(), table.lower()):
assert await ds.allowed_many(
actions=["view-table", "inspect-derived"],
resource=TableResource("data", spelling),
actor=actor,
) == {"view-table": expected, "inspect-derived": expected}
page = await ds.allowed_resources(
"view-table", actor, parent="data", include_is_private=True, limit=1000
)
allowed = {resource.child for resource in page.resources}
assert {"Documents", "Search"}.issubset(allowed)
assert "Nested" not in allowed
assert "Search_docsize" not in allowed
finally:
_permission_check_cache.reset(token)
ds.pm.unregister(name="inspect-derived-test")
ds.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("listing", [False, True], ids=["individual", "listing"])
async def test_derived_permission_discovery_error_is_retried(monkeypatch, listing):
ds = Datasette(memory=True)
db = ds.add_memory_database(f"derived_discovery_error_{listing}", name="data")
await db.execute_write("create table documents (id integer primary key)")
await ds.invoke_startup()
class UnavailableSchema:
def execute(self, sql):
raise sqlite3.DatabaseError("schema temporarily unavailable")
async def check():
if listing:
return await ds.allowed_resources("view-table", parent="data")
return await ds.allowed(
action="view-table", resource=TableResource("data", "documents")
)
token = _permission_check_cache.set({})
try:
with monkeypatch.context() as patch:
patch.setattr(
"datasette.database.sqlite_derived_table_dependencies",
lambda conn: sqlite_derived_table_dependencies(UnavailableSchema()),
)
with pytest.raises(sqlite3.DatabaseError, match="schema temporarily"):
await check()
# Failed discovery must not cache an empty map or a permission grant.
assert db._cached_derived_table_dependencies is None
assert not _permission_check_cache.get()
result = await check()
if listing:
assert [resource.child for resource in result.resources] == ["documents"]
else:
assert result is True
assert db._cached_derived_table_dependencies is not None
finally:
_permission_check_cache.reset(token)
@pytest.mark.asyncio
@pytest.mark.parametrize("fts_module", ("fts4", "fts5"))
async def test_external_content_fts_inherits_content_table_view_permission(fts_module):
actor = {"id": "reader"}
secret_marker = "ISSUE_17_EXTERNAL_CONTENT_FTS_SECRET"
ds = Datasette(
memory=True,
config={
"permissions": {
"view-instance": {"id": "reader"},
"view-database": {"id": "reader"},
"view-table": {"id": "reader"},
"execute-sql": {"id": "nobody"},
},
"databases": {
"data": {
"tables": {
"secret": {"permissions": {"view-table": False}},
}
}
},
},
)
db = ds.add_memory_database(f"issue_17_{fts_module}_permissions", name="data")
await db.execute_write("create table secret (id integer primary key, body text)")
await db.execute_write(
"insert into secret (body) values (?)",
[secret_marker],
)
fts_options = "body, content='secret'"
if fts_module == "fts5":
fts_options += ", content_rowid='id'"
await db.execute_write(
f"create virtual table secret_fts using {fts_module}({fts_options})"
)
await db.execute_write("insert into secret_fts(secret_fts) values ('rebuild')")
await ds.invoke_startup()
try:
assert "secret_fts" in await db.hidden_table_names()
assert (
await ds.allowed(
action="execute-sql",
resource=DatabaseResource("data"),
actor=actor,
)
is False
)
direct = await ds.client.get("/data/secret.json", actor=actor)
assert direct.status_code == 403
companion = await ds.client.get(
"/data/secret_fts.json?_shape=array",
actor=actor,
)
assert companion.status_code in (403, 404), (
"An automatically hidden external-content FTS table must inherit "
"the content table's view denial or be unavailable: "
f"{companion.text}"
)
assert secret_marker not in companion.text
finally:
ds.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("fts_module", ("fts4", "fts5"))
@pytest.mark.parametrize("contentless", (False, True), ids=("internal", "contentless"))
async def test_fts_shadow_tables_inherit_logical_table_view_permission(
fts_module, contentless
):
table_config = {
"secret_fts": {"permissions": {"view-table": False}},
# An explicit allow on one implementation table must not override
# the logical FTS table's denial.
"secret_fts_docsize": {"permissions": {"view-table": True}},
}
ds = Datasette(
memory=True,
config={
"permissions": {
"view-instance": True,
"view-database": True,
"view-table": True,
"execute-sql": False,
},
"databases": {"data": {"tables": table_config}},
},
)
db = ds.add_memory_database(
f"issue_17_{fts_module}_{'contentless' if contentless else 'internal'}",
name="data",
)
options = "body, content=''" if contentless else "body"
await db.execute_write(
f"create virtual table secret_fts using {fts_module}({options})"
)
await db.execute_write(
"insert into secret_fts(rowid, body) values (1, 'ISSUE_17_SHADOW_SECRET')"
)
await ds.invoke_startup()
try:
dependencies = await db.derived_table_dependencies()
shadow_tables = sorted(
table for table, source in dependencies.items() if source == "secret_fts"
)
assert shadow_tables
assert "secret_fts_docsize" in shadow_tables
for shadow_table in shadow_tables:
assert (
await ds.allowed(
action="view-table",
resource=TableResource("data", shadow_table),
)
is False
)
response = await ds.client.get(f"/data/{shadow_table}.json?_shape=array")
assert response.status_code == 403
assert "ISSUE_17_SHADOW_SECRET" not in response.text
allowed = await ds.allowed_resources("view-table", parent="data", limit=1000)
allowed_names = {resource.child for resource in allowed.resources}
assert not set(shadow_tables).intersection(allowed_names)
database_json = await ds.client.get("/data.json")
assert database_json.status_code == 200
for shadow_table in shadow_tables:
assert shadow_table not in database_json.text
schema_json = await ds.client.get("/data/-/schema.json")
assert schema_json.status_code == 200
for shadow_table in shadow_tables:
assert shadow_table not in schema_json.text
finally:
ds.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"content_allowed,companion_allowed,expected",
(
(False, True, False),
(True, False, False),
(True, True, True),
),
)
async def test_external_content_and_companion_permissions_are_both_required(
content_allowed, companion_allowed, expected
):
ds = Datasette(
memory=True,
default_deny=True,
config={
"permissions": {
"view-instance": True,
"view-database": True,
},
"databases": {
"data": {
"tables": {
"secret": {"permissions": {"view-table": content_allowed}},
"secret_fts": {
"permissions": {"view-table": companion_allowed}
},
}
}
},
},
)
db = ds.add_memory_database(
f"issue_17_explicit_{int(content_allowed)}_{int(companion_allowed)}",
name="data",
)
await db.execute_write("create table secret(id integer primary key, body text)")
await db.execute_write("insert into secret(body) values ('ISSUE_17_MATRIX_SECRET')")
await db.execute_write(
"create virtual table secret_fts using fts5("
"body, content='secret', content_rowid='id')"
)
await db.execute_write("insert into secret_fts(secret_fts) values ('rebuild')")
await ds.invoke_startup()
try:
assert (
await ds.allowed(
action="view-table",
resource=TableResource("data", "secret_fts"),
)
is expected
)
response = await ds.client.get("/data/secret_fts.json?_shape=array")
assert response.status_code == (200 if expected else 403)
if not expected:
assert "ISSUE_17_MATRIX_SECRET" not in response.text
finally:
ds.close()
@pytest.mark.asyncio
async def test_derived_tables_propagate_private_flag_and_route_permissions():
actor = {"id": "reader"}
ds = Datasette(
memory=True,
config={
"permissions": {
"view-instance": True,
"view-database": True,
"view-table": True,
},
"databases": {
"data": {
"tables": {
"secret": {"permissions": {"view-table": {"id": "reader"}}}
}
}
},
},
)
db = ds.add_memory_database("issue_17_private_flag", name="data")
await db.execute_write("create table secret(id integer primary key, body text)")
await db.execute_write("insert into secret(body) values ('PRIVATE')")
await db.execute_write(
"create virtual table secret_fts using fts5("
"body, content='secret', content_rowid='id')"
)
await db.execute_write("insert into secret_fts(secret_fts) values ('rebuild')")
await ds.invoke_startup()
try:
actor_page = await ds.allowed_resources(
"view-table", actor, parent="data", include_is_private=True, limit=1000
)
actor_resources = {
resource.child: resource for resource in actor_page.resources
}
derived_names = set(await db.derived_table_dependencies())
assert "secret_fts" in actor_resources
assert actor_resources["secret_fts"].private
# Shadow tables depend on the already-derived external-content FTS
# table, so they remain unavailable even to the permitted reader.
assert not (derived_names - {"secret_fts"}).intersection(actor_resources)
anonymous_page = await ds.allowed_resources(
"view-table", parent="data", limit=1000
)
anonymous_names = {resource.child for resource in anonymous_page.resources}
assert not derived_names.intersection(anonymous_names)
for path in (
"/data/secret_fts.json?_facet=body",
"/data/secret_fts.csv",
"/data/secret_fts/-/autocomplete?q=PRIVATE",
"/data/secret_fts/-/schema.json",
):
denied = await ds.client.get(path)
assert denied.status_code == 403
allowed = await ds.client.get(path, actor=actor)
assert allowed.status_code == 200
finally:
ds.close()
@pytest.mark.asyncio
async def test_cyclic_derived_table_dependencies_fail_closed():
ds = Datasette(memory=True)
db = ds.add_memory_database("issue_17_cycle", name="data")
await db.execute_write(
"create virtual table first_fts using fts5(body, content='second_fts')"
)
await db.execute_write(
"create virtual table second_fts using fts5(body, content='first_fts')"
)
await ds.invoke_startup()
try:
for table in ("first_fts", "second_fts"):
assert (
await ds.allowed(
action="view-table", resource=TableResource("data", table)
)
is False
)
page = await ds.allowed_resources("view-table", parent="data", limit=1000)
allowed_names = {resource.child for resource in page.resources}
assert "first_fts" not in allowed_names
assert "second_fts" not in allowed_names
finally:
ds.close()