mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
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:
parent
a2994cc5bb
commit
e8b79970fb
6 changed files with 460 additions and 366 deletions
|
|
@ -60,6 +60,7 @@ def register_actions():
|
|||
takes_parent=True,
|
||||
takes_child=False,
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
# Debug actions
|
||||
Action(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue