Implement also_requires to enforce view-database for execute-sql

Adds Action.also_requires field to specify dependencies between permissions.
When an action has also_requires set, users must have permission for BOTH
the main action AND the required action on a resource.

Applies this to execute-sql, which now requires view-database permission.
This prevents the illogical scenario where users can execute SQL on a
database they cannot view.

Changes:
- Add also_requires field to Action dataclass in datasette/permissions.py
- Update execute-sql action with also_requires="view-database"
- Implement also_requires handling in build_allowed_resources_sql()
- Implement also_requires handling in AllowedResourcesView endpoint
- Add test verifying execute-sql requires view-database permission

Fixes #2527
This commit is contained in:
Simon Willison 2025-10-24 11:54:37 -07:00
commit e8b79970fb
6 changed files with 460 additions and 366 deletions

View file

@ -60,6 +60,7 @@ def register_actions():
takes_parent=True,
takes_child=False,
resource_class=DatabaseResource,
also_requires="view-database",
),
# Debug actions
Action(

View file

@ -76,6 +76,7 @@ class Action:
takes_parent: bool
takes_child: bool
resource_class: type[Resource]
also_requires: str | None = None # Optional action name that must also be allowed
@dataclass

View file

@ -100,6 +100,82 @@ async def build_allowed_resources_sql(
if not action_obj:
raise ValueError(f"Unknown action: {action}")
# If this action also_requires another action, we need to combine the queries
if action_obj.also_requires:
# Build both queries
main_sql, main_params = await _build_single_action_sql(
datasette,
actor,
action,
parent=parent,
include_is_private=include_is_private,
)
required_sql, required_params = await _build_single_action_sql(
datasette,
actor,
action_obj.also_requires,
parent=parent,
include_is_private=False,
)
# Merge parameters - they should have identical values for :actor, :actor_id, etc.
all_params = {**main_params, **required_params}
if parent is not None:
all_params["filter_parent"] = parent
# Combine with INNER JOIN - only resources allowed by both actions
combined_sql = f"""
WITH
main_allowed AS (
{main_sql}
),
required_allowed AS (
{required_sql}
)
SELECT m.parent, m.child, m.reason"""
if include_is_private:
combined_sql += ", m.is_private"
combined_sql += """
FROM main_allowed m
INNER JOIN required_allowed r
ON ((m.parent = r.parent) OR (m.parent IS NULL AND r.parent IS NULL))
AND ((m.child = r.child) OR (m.child IS NULL AND r.child IS NULL))
"""
if parent is not None:
combined_sql += "WHERE m.parent = :filter_parent\n"
combined_sql += "ORDER BY m.parent, m.child"
return combined_sql, all_params
# No also_requires, build single action query
return await _build_single_action_sql(
datasette, actor, action, parent=parent, include_is_private=include_is_private
)
async def _build_single_action_sql(
datasette: "Datasette",
actor: dict | None,
action: str,
*,
parent: str | None = None,
include_is_private: bool = False,
) -> tuple[str, dict]:
"""
Build SQL for a single action (internal helper for build_allowed_resources_sql).
This contains the original logic from build_allowed_resources_sql, extracted
to allow combining multiple actions when also_requires is used.
"""
# Get the Action object
action_obj = datasette.actions.get(action)
if not action_obj:
raise ValueError(f"Unknown action: {action}")
# Get base resources SQL from the resource class
base_resources_sql = action_obj.resource_class.resources_sql()

View file

@ -289,35 +289,125 @@ class AllowedResourcesView(BaseView):
headers=headers,
)
plugins = []
for block in pm.hook.permission_resources_sql(
datasette=self.ds,
actor=actor,
action=action,
):
block = await await_me_maybe(block)
if block is None:
continue
if isinstance(block, (list, tuple)):
candidates = block
else:
candidates = [block]
for candidate in candidates:
if candidate is None:
# Check if this action requires another action
action_obj = self.ds.actions.get(action)
if action_obj and action_obj.also_requires:
# Need to combine results from both actions
# Get allowed resources for the main action
plugins = []
for block in pm.hook.permission_resources_sql(
datasette=self.ds,
actor=actor,
action=action,
):
block = await await_me_maybe(block)
if block is None:
continue
plugins.append(candidate)
if isinstance(block, (list, tuple)):
candidates = block
else:
candidates = [block]
for candidate in candidates:
if candidate is None:
continue
plugins.append(candidate)
rows = await resolve_permissions_from_catalog(
db,
actor=actor,
plugins=plugins,
action=action,
candidate_sql=candidate_sql,
candidate_params=candidate_params,
implicit_deny=True,
)
main_rows = await resolve_permissions_from_catalog(
db,
actor=actor,
plugins=plugins,
action=action,
candidate_sql=candidate_sql,
candidate_params=candidate_params,
implicit_deny=True,
)
main_allowed = {
(row["parent"], row["child"]) for row in main_rows if row["allow"] == 1
}
allowed_rows = [row for row in rows if row["allow"] == 1]
# Get allowed resources for the required action
required_action = action_obj.also_requires
required_candidate_sql, required_candidate_params = self.CANDIDATE_SQL.get(
required_action, (None, None)
)
if not required_candidate_sql:
# If the required action doesn't have candidate SQL, deny everything
allowed_rows = []
else:
required_plugins = []
for block in pm.hook.permission_resources_sql(
datasette=self.ds,
actor=actor,
action=required_action,
):
block = await await_me_maybe(block)
if block is None:
continue
if isinstance(block, (list, tuple)):
candidates = block
else:
candidates = [block]
for candidate in candidates:
if candidate is None:
continue
required_plugins.append(candidate)
required_rows = await resolve_permissions_from_catalog(
db,
actor=actor,
plugins=required_plugins,
action=required_action,
candidate_sql=required_candidate_sql,
candidate_params=required_candidate_params,
implicit_deny=True,
)
required_allowed = {
(row["parent"], row["child"])
for row in required_rows
if row["allow"] == 1
}
# Intersect the two sets - only resources allowed by BOTH actions
allowed_resources = main_allowed & required_allowed
# Get full row data for the allowed resources
allowed_rows = [
row
for row in main_rows
if row["allow"] == 1
and (row["parent"], row["child"]) in allowed_resources
]
else:
# No also_requires, use normal path
plugins = []
for block in pm.hook.permission_resources_sql(
datasette=self.ds,
actor=actor,
action=action,
):
block = await await_me_maybe(block)
if block is None:
continue
if isinstance(block, (list, tuple)):
candidates = block
else:
candidates = [block]
for candidate in candidates:
if candidate is None:
continue
plugins.append(candidate)
rows = await resolve_permissions_from_catalog(
db,
actor=actor,
plugins=plugins,
action=action,
candidate_sql=candidate_sql,
candidate_params=candidate_params,
implicit_deny=True,
)
allowed_rows = [row for row in rows if row["allow"] == 1]
if parent_filter is not None:
allowed_rows = [
row for row in allowed_rows if row["parent"] == parent_filter