mirror of
https://github.com/simonw/datasette.git
synced 2026-06-13 20:46:58 +02:00
datasette.allowed_many() method
This commit is contained in:
parent
fa86ac7b11
commit
88878b4184
6 changed files with 614 additions and 101 deletions
101
datasette/app.py
101
datasette/app.py
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import contextvars
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.permissions import Resource
|
||||
|
|
@ -1817,46 +1817,97 @@ class Datasette:
|
|||
# For global actions, resource can be omitted:
|
||||
can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
|
||||
"""
|
||||
from datasette.utils.actions_sql import check_permission_for_resource
|
||||
results = await self.allowed_many(
|
||||
actions=[action], resource=resource, actor=actor
|
||||
)
|
||||
return results[action]
|
||||
|
||||
# For global actions, resource remains None
|
||||
async def allowed_many(
|
||||
self,
|
||||
*,
|
||||
actions: Sequence[str],
|
||||
resource: "Resource" = None,
|
||||
actor: dict | None = None,
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
Check several actions against one resource for one actor.
|
||||
|
||||
# Check if this action has also_requires - if so, check that action first
|
||||
action_obj = self.actions.get(action)
|
||||
if action_obj and action_obj.also_requires:
|
||||
# Must have the required action first
|
||||
if not await self.allowed(
|
||||
action=action_obj.also_requires,
|
||||
resource=resource,
|
||||
Resolves every action (plus any also_requires dependencies) with a
|
||||
single internal database query, instead of one or two queries per
|
||||
action.
|
||||
|
||||
Example:
|
||||
from datasette.resources import TableResource
|
||||
results = await datasette.allowed_many(
|
||||
actions=["edit-schema", "drop-table", "insert-row"],
|
||||
resource=TableResource(database="data", table="exercise"),
|
||||
actor=actor,
|
||||
):
|
||||
return False
|
||||
)
|
||||
# {"edit-schema": True, "drop-table": True, "insert-row": False}
|
||||
"""
|
||||
from datasette.utils.actions_sql import check_permissions_for_actions
|
||||
|
||||
# For global actions, resource is None
|
||||
parent = resource.parent if resource else None
|
||||
child = resource.child if resource else None
|
||||
|
||||
result = await check_permission_for_resource(
|
||||
# Expand also_requires dependencies (transitively) so that each
|
||||
# dependency is resolved within the same batch
|
||||
expanded = []
|
||||
|
||||
def add_action(name):
|
||||
if name in expanded:
|
||||
return
|
||||
action_obj = self.actions.get(name)
|
||||
if action_obj is None:
|
||||
raise ValueError(f"Unknown action: {name}")
|
||||
expanded.append(name)
|
||||
if action_obj.also_requires:
|
||||
add_action(action_obj.also_requires)
|
||||
|
||||
requested = list(dict.fromkeys(actions))
|
||||
for name in requested:
|
||||
add_action(name)
|
||||
|
||||
raw = await check_permissions_for_actions(
|
||||
datasette=self,
|
||||
actor=actor,
|
||||
action=action,
|
||||
actions=expanded,
|
||||
parent=parent,
|
||||
child=child,
|
||||
)
|
||||
final = {}
|
||||
|
||||
# Log the permission check for debugging
|
||||
self._permission_checks.append(
|
||||
PermissionCheck(
|
||||
when=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
actor=actor,
|
||||
action=action,
|
||||
parent=parent,
|
||||
child=child,
|
||||
result=result,
|
||||
def resolve(name):
|
||||
# final verdict = own rules AND verdict of also_requires chain
|
||||
if name in final:
|
||||
return final[name]
|
||||
result = raw[name]
|
||||
action_obj = self.actions.get(name)
|
||||
if result and action_obj.also_requires:
|
||||
result = resolve(action_obj.also_requires)
|
||||
final[name] = result
|
||||
return result
|
||||
|
||||
for name in expanded:
|
||||
resolve(name)
|
||||
|
||||
# Log every check for the debug page, dependencies before the
|
||||
# actions that required them
|
||||
when = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
for name in reversed(expanded):
|
||||
self._permission_checks.append(
|
||||
PermissionCheck(
|
||||
when=when,
|
||||
actor=actor,
|
||||
action=name,
|
||||
parent=parent,
|
||||
child=child,
|
||||
result=final[name],
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
return {name: final[name] for name in requested}
|
||||
|
||||
async def ensure_permission(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ The core pattern is:
|
|||
- Across levels, child beats parent beats global
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from datasette.utils.permissions import gather_permission_sql_from_hooks
|
||||
|
|
@ -495,6 +497,146 @@ async def build_permission_rules_sql(
|
|||
return rules_union, all_params, restriction_sqls
|
||||
|
||||
|
||||
async def check_permissions_for_actions(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
actions: list[str],
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
Check several actions for one actor and resource in a single query.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
actor: The actor dict (or None)
|
||||
actions: List of action names to check
|
||||
parent: The parent resource identifier (e.g., database name, or None)
|
||||
child: The child resource identifier (e.g., table name, or None)
|
||||
|
||||
Returns:
|
||||
Dict mapping each action name to True (allowed) or False (denied)
|
||||
|
||||
Each action contributes its own tagged block of permission rules
|
||||
(gathered from the permission_resources_sql hook, with parameters
|
||||
namespaced per action to avoid collisions) plus an optional
|
||||
restriction allowlist CTE. One internal database query resolves
|
||||
the winning rule per action using the same specificity-then-deny
|
||||
ordering as the rest of the permission system.
|
||||
|
||||
Note: this resolves each action independently - also_requires
|
||||
dependencies are handled by the caller (Datasette.allowed_many).
|
||||
"""
|
||||
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
||||
|
||||
for action in actions:
|
||||
if not datasette.actions.get(action):
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
# Dedupe while preserving order
|
||||
unique_actions = list(dict.fromkeys(actions))
|
||||
if not unique_actions:
|
||||
return {}
|
||||
|
||||
# Gather hook results for each action concurrently - hooks within a
|
||||
# single action still run sequentially, preserving existing semantics
|
||||
gathered = await asyncio.gather(
|
||||
*(
|
||||
gather_permission_sql_from_hooks(
|
||||
datasette=datasette, actor=actor, action=action
|
||||
)
|
||||
for action in unique_actions
|
||||
)
|
||||
)
|
||||
|
||||
if any(result is SKIP_PERMISSION_CHECKS for result in gathered):
|
||||
return {action: True for action in unique_actions}
|
||||
|
||||
params = {"_check_parent": parent, "_check_child": child}
|
||||
ctes = []
|
||||
selects = []
|
||||
verdicts = {}
|
||||
|
||||
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
|
||||
prefix = f"a{i}_"
|
||||
rule_parts = []
|
||||
restriction_parts = []
|
||||
|
||||
for permission_sql in permission_sqls:
|
||||
sql = permission_sql.sql
|
||||
restriction_sql = permission_sql.restriction_sql
|
||||
# Namespace this block's params so identical names used for
|
||||
# different actions cannot collide
|
||||
for key in permission_sql.params or {}:
|
||||
new_key = prefix + key
|
||||
params[new_key] = permission_sql.params[key]
|
||||
pattern = re.compile(":" + re.escape(key) + r"(?![A-Za-z0-9_])")
|
||||
if sql:
|
||||
sql = pattern.sub(":" + new_key, sql)
|
||||
if restriction_sql:
|
||||
restriction_sql = pattern.sub(":" + new_key, restriction_sql)
|
||||
|
||||
if restriction_sql:
|
||||
restriction_parts.append(restriction_sql)
|
||||
|
||||
# Skip plugins that only provide restriction_sql (no permission rules)
|
||||
if sql is None:
|
||||
continue
|
||||
rule_parts.append(
|
||||
f"SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
|
||||
)
|
||||
|
||||
if not rule_parts:
|
||||
# No rules from any plugin - default deny. Restrictions can
|
||||
# only restrict, never grant, so no SQL is needed at all
|
||||
verdicts[action] = False
|
||||
continue
|
||||
ctes.append(f"a{i}_rules AS (\n" + "\nUNION ALL\n".join(rule_parts) + "\n)")
|
||||
|
||||
# Winning rule for this action: most specific depth first, then
|
||||
# deny-beats-allow, then source_plugin as a stable tie-break
|
||||
verdict_sql = f"""COALESCE((
|
||||
SELECT allow FROM (
|
||||
SELECT allow, source_plugin,
|
||||
CASE
|
||||
WHEN child IS NOT NULL THEN 2
|
||||
WHEN parent IS NOT NULL THEN 1
|
||||
ELSE 0
|
||||
END AS depth
|
||||
FROM a{i}_rules
|
||||
WHERE (parent IS NULL OR parent = :_check_parent)
|
||||
AND (child IS NULL OR child = :_check_child)
|
||||
ORDER BY
|
||||
depth DESC,
|
||||
CASE WHEN allow = 0 THEN 0 ELSE 1 END,
|
||||
source_plugin
|
||||
LIMIT 1
|
||||
)
|
||||
), 0)"""
|
||||
|
||||
if restriction_parts:
|
||||
# Database-level restrictions (parent, NULL) match all children
|
||||
restriction_intersect = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_parts
|
||||
)
|
||||
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
|
||||
verdict_sql = f"""({verdict_sql}) AND EXISTS (
|
||||
SELECT 1 FROM a{i}_restriction r
|
||||
WHERE (r.parent = :_check_parent OR r.parent IS NULL)
|
||||
AND (r.child = :_check_child OR r.child IS NULL)
|
||||
)"""
|
||||
|
||||
selects.append(f"SELECT {i} AS action_idx, ({verdict_sql}) AS is_allowed")
|
||||
|
||||
if selects:
|
||||
query = "WITH\n" + ",\n".join(ctes) + "\n" + "\nUNION ALL\n".join(selects)
|
||||
result = await datasette.get_internal_database().execute(query, params)
|
||||
for row in result.rows:
|
||||
verdicts[unique_actions[row[0]]] = bool(row[1])
|
||||
return verdicts
|
||||
|
||||
|
||||
async def check_permission_for_resource(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
|
|
@ -515,77 +657,12 @@ async def check_permission_for_resource(
|
|||
|
||||
Returns:
|
||||
True if the actor is allowed, False otherwise
|
||||
|
||||
This builds the cascading permission query and checks if the specific
|
||||
resource is in the allowed set.
|
||||
"""
|
||||
rules_union, all_params, restriction_sqls = await build_permission_rules_sql(
|
||||
datasette, actor, action
|
||||
results = await check_permissions_for_actions(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
actions=[action],
|
||||
parent=parent,
|
||||
child=child,
|
||||
)
|
||||
|
||||
# If no rules (empty SQL), default deny
|
||||
if not rules_union:
|
||||
return False
|
||||
|
||||
# Add parameters for the resource we're checking
|
||||
all_params["_check_parent"] = parent
|
||||
all_params["_check_child"] = child
|
||||
|
||||
# If there are restriction filters, check if the resource passes them first
|
||||
if restriction_sqls:
|
||||
# Check if resource is in restriction allowlist
|
||||
# Database-level restrictions (parent, NULL) should match all children (parent, *)
|
||||
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
||||
restriction_check = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||
)
|
||||
restriction_query = f"""
|
||||
WITH restriction_list AS (
|
||||
{restriction_check}
|
||||
)
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM restriction_list
|
||||
WHERE (parent = :_check_parent OR parent IS NULL)
|
||||
AND (child = :_check_child OR child IS NULL)
|
||||
) AS in_allowlist
|
||||
"""
|
||||
result = await datasette.get_internal_database().execute(
|
||||
restriction_query, all_params
|
||||
)
|
||||
if result.rows and not result.rows[0][0]:
|
||||
# Resource not in restriction allowlist - deny
|
||||
return False
|
||||
|
||||
query = f"""
|
||||
WITH
|
||||
all_rules AS (
|
||||
{rules_union}
|
||||
),
|
||||
matched_rules AS (
|
||||
SELECT ar.*,
|
||||
CASE
|
||||
WHEN ar.child IS NOT NULL THEN 2 -- child-level (most specific)
|
||||
WHEN ar.parent IS NOT NULL THEN 1 -- parent-level
|
||||
ELSE 0 -- root/global
|
||||
END AS depth
|
||||
FROM all_rules ar
|
||||
WHERE (ar.parent IS NULL OR ar.parent = :_check_parent)
|
||||
AND (ar.child IS NULL OR ar.child = :_check_child)
|
||||
),
|
||||
winner AS (
|
||||
SELECT *
|
||||
FROM matched_rules
|
||||
ORDER BY
|
||||
depth DESC, -- specificity first (higher depth wins)
|
||||
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow
|
||||
source_plugin -- stable tie-break
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT COALESCE((SELECT allow FROM winner), 0) AS is_allowed
|
||||
"""
|
||||
|
||||
# Execute the query against the internal database
|
||||
result = await datasette.get_internal_database().execute(query, all_params)
|
||||
if result.rows:
|
||||
return bool(result.rows[0][0])
|
||||
return False
|
||||
return results[action]
|
||||
|
|
|
|||
|
|
@ -512,6 +512,39 @@ Example usage:
|
|||
|
||||
The method returns ``True`` if the permission is granted, ``False`` if denied.
|
||||
|
||||
.. _datasette_allowed_many:
|
||||
|
||||
await .allowed_many(\*, actions, resource, actor=None)
|
||||
------------------------------------------------------
|
||||
|
||||
``actions`` - list of strings
|
||||
The names of the actions to permission check.
|
||||
|
||||
``resource`` - Resource object
|
||||
A Resource object representing the database, table, or other resource that each action is checked against. Omit for global actions.
|
||||
|
||||
``actor`` - dictionary, optional
|
||||
The authenticated actor. This is usually ``request.actor``. Defaults to ``None`` for unauthenticated requests.
|
||||
|
||||
Checks several actions against the same resource for the same actor, returning a dictionary mapping each action name to ``True`` or ``False``. The whole batch - including any actions pulled in through ``also_requires`` dependencies - is resolved with a single SQL query against the internal database, so this is much faster than calling :ref:`datasette.allowed() <datasette_allowed>` once per action.
|
||||
|
||||
Example usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette.resources import TableResource
|
||||
|
||||
results = await datasette.allowed_many(
|
||||
actions=["insert-row", "delete-row", "drop-table"],
|
||||
resource=TableResource(
|
||||
database="fixtures", table="facetable"
|
||||
),
|
||||
actor=request.actor,
|
||||
)
|
||||
# {"insert-row": True, "delete-row": True, "drop-table": False}
|
||||
|
||||
Actions for which no plugin provides any permission rules are resolved to ``False`` directly, without being included in the SQL query at all.
|
||||
|
||||
.. _datasette_allowed_resources:
|
||||
|
||||
await .allowed_resources(action, actor=None, \*, parent=None, include_is_private=False, include_reasons=False, limit=100, next=None)
|
||||
|
|
|
|||
|
|
@ -1458,6 +1458,12 @@ to avoid conflicts with other plugins. The recommended convention is to prefix p
|
|||
plugin's source name (e.g., ``myplugin_user_id``). The system reserves these parameter names:
|
||||
``:actor``, ``:actor_id``, ``:action``, and ``:filter_parent``.
|
||||
|
||||
This hook may be called for many actions in rapid succession - for example
|
||||
:ref:`datasette.allowed_many() <datasette_allowed_many>` gathers rules for every action in its batch
|
||||
concurrently. Hook implementations must not assume that checks for different actions arrive one
|
||||
page-render apart, and expensive work (such as network calls) should be cached independently of the
|
||||
``action`` argument where possible.
|
||||
|
||||
You can also use return ``PermissionSQL.allow(reason="reason goes here")`` or ``PermissionSQL.deny(reason="reason goes here")`` as shortcuts for simple root-level allow or deny rules. These will create SQL snippets that look like this:
|
||||
|
||||
.. code-block:: sql
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ def restore_working_directory(tmpdir, request):
|
|||
@pytest.fixture(scope="session", autouse=True)
|
||||
def check_actions_are_documented():
|
||||
from datasette.plugins import pm
|
||||
from datasette.default_actions import register_actions as default_register_actions
|
||||
|
||||
content = (
|
||||
pathlib.Path(__file__).parent.parent / "docs" / "authentication.rst"
|
||||
|
|
@ -154,6 +155,9 @@ def check_actions_are_documented():
|
|||
documented_actions = set(permissions_re.findall(content)).union(
|
||||
UNDOCUMENTED_PERMISSIONS
|
||||
)
|
||||
# Only Datasette core actions need to be documented - actions registered
|
||||
# by (test) plugins are checked for registration but not documentation
|
||||
core_actions = {action.name for action in default_register_actions()}
|
||||
|
||||
def before(hook_name, hook_impls, kwargs):
|
||||
if hook_name == "permission_resources_sql":
|
||||
|
|
@ -165,9 +169,10 @@ def check_actions_are_documented():
|
|||
+ " (or maybe a test forgot to do await ds.invoke_startup())"
|
||||
)
|
||||
action = kwargs.get("action").replace("-", "_")
|
||||
assert (
|
||||
action in documented_actions
|
||||
), "Undocumented permission action: {}".format(action)
|
||||
if kwargs["action"] in core_actions:
|
||||
assert (
|
||||
action in documented_actions
|
||||
), "Undocumented permission action: {}".format(action)
|
||||
|
||||
pm.add_hookcall_monitoring(
|
||||
before=before, after=lambda outcome, hook_name, hook_impls, kwargs: None
|
||||
|
|
|
|||
341
tests/test_allowed_many.py
Normal file
341
tests/test_allowed_many.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
"""
|
||||
Tests for the datasette.allowed_many() batch permission API, which
|
||||
resolves multiple actions against one resource in a single internal
|
||||
database query. datasette.allowed() is implemented on top of it, so
|
||||
both entry points share one resolution code path.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from datasette.app import Datasette
|
||||
from datasette.permissions import PermissionSQL, SkipPermissions
|
||||
from datasette.resources import DatabaseResource, TableResource
|
||||
from datasette import hookimpl
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ds():
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
db = ds.add_memory_database("analytics")
|
||||
await db.execute_write("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY)")
|
||||
await db.execute_write("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY)")
|
||||
await ds._refresh_schemas()
|
||||
return ds
|
||||
|
||||
|
||||
class MatrixRulesPlugin:
|
||||
"""Different rules per action for actor carol, to exercise resolution."""
|
||||
|
||||
@hookimpl
|
||||
def permission_resources_sql(self, datasette, actor, action):
|
||||
if not actor or actor.get("id") != "carol":
|
||||
return None
|
||||
if action == "view-table":
|
||||
return PermissionSQL(sql="""
|
||||
SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global allow' AS reason
|
||||
UNION ALL
|
||||
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, 'deny sensitive' AS reason
|
||||
""")
|
||||
if action == "insert-row":
|
||||
return PermissionSQL(
|
||||
sql="SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analytics writes' AS reason"
|
||||
)
|
||||
# Everything else: no opinion (implicit deny unless defaults allow)
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_basic(ds):
|
||||
plugin = MatrixRulesPlugin()
|
||||
ds.pm.register(plugin, name="matrix")
|
||||
try:
|
||||
results = await ds.allowed_many(
|
||||
actions=["view-table", "insert-row", "drop-table"],
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor={"id": "carol"},
|
||||
)
|
||||
assert results == {
|
||||
"view-table": True,
|
||||
"insert-row": True,
|
||||
"drop-table": False,
|
||||
}
|
||||
# Child-level deny beats global allow
|
||||
sensitive = await ds.allowed_many(
|
||||
actions=["view-table"],
|
||||
resource=TableResource("analytics", "sensitive"),
|
||||
actor={"id": "carol"},
|
||||
)
|
||||
assert sensitive == {"view-table": False}
|
||||
finally:
|
||||
ds.pm.unregister(name="matrix")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_matches_allowed(ds):
|
||||
"""Every action resolved by allowed_many() must match allowed()."""
|
||||
plugin = MatrixRulesPlugin()
|
||||
ds.pm.register(plugin, name="matrix")
|
||||
try:
|
||||
all_actions = list(ds.actions)
|
||||
for resource in (
|
||||
TableResource("analytics", "users"),
|
||||
TableResource("analytics", "sensitive"),
|
||||
DatabaseResource("analytics"),
|
||||
):
|
||||
batched = await ds.allowed_many(
|
||||
actions=all_actions, resource=resource, actor={"id": "carol"}
|
||||
)
|
||||
assert set(batched) == set(all_actions)
|
||||
for action in all_actions:
|
||||
individual = await ds.allowed(
|
||||
action=action, resource=resource, actor={"id": "carol"}
|
||||
)
|
||||
assert (
|
||||
batched[action] == individual
|
||||
), f"Mismatch for {action} on {resource}"
|
||||
finally:
|
||||
ds.pm.unregister(name="matrix")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_unknown_action_raises(ds):
|
||||
with pytest.raises(ValueError, match="Unknown action"):
|
||||
await ds.allowed_many(
|
||||
actions=["view-table", "no-such-action"],
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_empty_actions(ds):
|
||||
assert (
|
||||
await ds.allowed_many(
|
||||
actions=[], resource=TableResource("analytics", "users"), actor=None
|
||||
)
|
||||
== {}
|
||||
)
|
||||
|
||||
|
||||
class AlsoRequiresRulesPlugin:
|
||||
"""dave: store-query allowed but execute-sql explicitly denied.
|
||||
erin: store-query allowed (execute-sql stays default-allowed)."""
|
||||
|
||||
@hookimpl
|
||||
def permission_resources_sql(self, datasette, actor, action):
|
||||
actor_id = actor.get("id") if actor else None
|
||||
if actor_id == "dave":
|
||||
if action == "store-query":
|
||||
return PermissionSQL(
|
||||
sql="SELECT NULL AS parent, NULL AS child, 1 AS allow, 'dave can store' AS reason"
|
||||
)
|
||||
if action == "execute-sql":
|
||||
return PermissionSQL(
|
||||
sql="SELECT NULL AS parent, NULL AS child, 0 AS allow, 'dave no sql' AS reason"
|
||||
)
|
||||
if actor_id == "erin" and action == "store-query":
|
||||
return PermissionSQL(
|
||||
sql="SELECT NULL AS parent, NULL AS child, 1 AS allow, 'erin can store' AS reason"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_also_requires(ds):
|
||||
# store-query also_requires execute-sql, which also_requires view-database
|
||||
plugin = AlsoRequiresRulesPlugin()
|
||||
ds.pm.register(plugin, name="also_requires")
|
||||
try:
|
||||
resource = DatabaseResource("analytics")
|
||||
dave = await ds.allowed_many(
|
||||
actions=["store-query", "execute-sql", "view-database"],
|
||||
resource=resource,
|
||||
actor={"id": "dave"},
|
||||
)
|
||||
# execute-sql denied, so store-query must be denied too
|
||||
assert dave == {
|
||||
"store-query": False,
|
||||
"execute-sql": False,
|
||||
"view-database": True,
|
||||
}
|
||||
erin = await ds.allowed_many(
|
||||
actions=["store-query"], resource=resource, actor={"id": "erin"}
|
||||
)
|
||||
assert erin == {"store-query": True}
|
||||
# Must match the single-check path
|
||||
assert (
|
||||
await ds.allowed(
|
||||
action="store-query", resource=resource, actor={"id": "dave"}
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
await ds.allowed(
|
||||
action="store-query", resource=resource, actor={"id": "erin"}
|
||||
)
|
||||
is True
|
||||
)
|
||||
finally:
|
||||
ds.pm.unregister(name="also_requires")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_respects_restrictions(ds):
|
||||
"""Token-style _r restrictions are enforced within the batch."""
|
||||
actor = {"id": "root", "_r": {"d": {"analytics": ["vt"]}}}
|
||||
results = await ds.allowed_many(
|
||||
actions=["view-table", "drop-table"],
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor=actor,
|
||||
)
|
||||
# root could normally do both, but the token only allows view-table
|
||||
# on the analytics database
|
||||
assert results == {"view-table": True, "drop-table": False}
|
||||
other_db = await ds.allowed_many(
|
||||
actions=["view-table"],
|
||||
resource=TableResource("production", "stuff"),
|
||||
actor=actor,
|
||||
)
|
||||
assert other_db == {"view-table": False}
|
||||
# Equivalence with allowed()
|
||||
assert (
|
||||
await ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor=actor,
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
await ds.allowed(
|
||||
action="drop-table",
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor=actor,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
class ParamCollisionPlugin:
|
||||
"""Same parameter name with a different value for every action."""
|
||||
|
||||
@hookimpl
|
||||
def permission_resources_sql(self, datasette, actor, action):
|
||||
if not actor or actor.get("id") != "paula":
|
||||
return None
|
||||
flag = 1 if action in ("drop-table", "insert-row") else 0
|
||||
return PermissionSQL(
|
||||
sql="SELECT NULL AS parent, NULL AS child, :flag AS allow, 'flagged' AS reason",
|
||||
params={"flag": flag},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_namespaces_params_across_actions(ds):
|
||||
"""Many actions whose rules use identical param names must not collide."""
|
||||
plugin = ParamCollisionPlugin()
|
||||
ds.pm.register(plugin, name="collision")
|
||||
try:
|
||||
all_actions = list(ds.actions)
|
||||
assert len(all_actions) >= 15
|
||||
resource = TableResource("analytics", "users")
|
||||
results = await ds.allowed_many(
|
||||
actions=all_actions, resource=resource, actor={"id": "paula"}
|
||||
)
|
||||
# Spot-check: only the flagged actions resolve True
|
||||
assert results["drop-table"] is True
|
||||
assert results["create-table"] is False
|
||||
# Full equivalence against single checks
|
||||
for action in all_actions:
|
||||
assert results[action] == await ds.allowed(
|
||||
action=action, resource=resource, actor={"id": "paula"}
|
||||
), f"Mismatch for {action}"
|
||||
finally:
|
||||
ds.pm.unregister(name="collision")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_single_internal_db_query(ds):
|
||||
internal_db = ds.get_internal_database()
|
||||
calls = []
|
||||
original_execute = internal_db.execute
|
||||
|
||||
async def counting_execute(sql, params=None, **kwargs):
|
||||
calls.append(sql)
|
||||
return await original_execute(sql, params, **kwargs)
|
||||
|
||||
internal_db.execute = counting_execute
|
||||
try:
|
||||
results = await ds.allowed_many(
|
||||
actions=["view-table", "insert-row", "delete-row", "drop-table"],
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor={"id": "root", "_r": {"d": {"analytics": ["vt"]}}},
|
||||
)
|
||||
assert len(results) == 4
|
||||
assert len(calls) == 1
|
||||
finally:
|
||||
internal_db.execute = original_execute
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_no_query_when_no_rules(ds):
|
||||
"""Actions with no rules from any plugin are denied without SQL.
|
||||
|
||||
Restrictions can only restrict, never grant, so an action with no
|
||||
rule rows is always False - it should not contribute to the query,
|
||||
and if no action has rules there should be no query at all."""
|
||||
internal_db = ds.get_internal_database()
|
||||
calls = []
|
||||
original_execute = internal_db.execute
|
||||
|
||||
async def counting_execute(sql, params=None, **kwargs):
|
||||
calls.append(sql)
|
||||
return await original_execute(sql, params, **kwargs)
|
||||
|
||||
internal_db.execute = counting_execute
|
||||
try:
|
||||
# bob gets no rules at all for these write actions
|
||||
results = await ds.allowed_many(
|
||||
actions=["drop-table", "delete-row"],
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor={"id": "bob"},
|
||||
)
|
||||
assert results == {"drop-table": False, "delete-row": False}
|
||||
assert len(calls) == 0
|
||||
# A mixed batch still needs exactly one query
|
||||
calls.clear()
|
||||
results = await ds.allowed_many(
|
||||
actions=["view-table", "drop-table"],
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor={"id": "bob"},
|
||||
)
|
||||
assert results == {"view-table": True, "drop-table": False}
|
||||
assert len(calls) == 1
|
||||
finally:
|
||||
internal_db.execute = original_execute
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_global_actions_without_resource(ds):
|
||||
results = await ds.allowed_many(
|
||||
actions=["view-instance", "permissions-debug"],
|
||||
actor={"id": "root"},
|
||||
)
|
||||
assert results["view-instance"] is True
|
||||
# Equivalence with single checks for global actions
|
||||
for action in ("view-instance", "permissions-debug"):
|
||||
assert results[action] == await ds.allowed(action=action, actor={"id": "root"})
|
||||
anon = await ds.allowed_many(actions=["permissions-debug"], actor=None)
|
||||
assert anon == {"permissions-debug": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_many_skip_permission_checks(ds):
|
||||
with SkipPermissions():
|
||||
results = await ds.allowed_many(
|
||||
actions=["view-table", "drop-table"],
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor=None,
|
||||
)
|
||||
assert results == {"view-table": True, "drop-table": True}
|
||||
Loading…
Add table
Add a link
Reference in a new issue