mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Permissions SQL API improvements (#2558)
* Neater design for PermissionSQL class, refs #2556 - source is now automatically set to the source plugin - params is optional * PermissionSQL.allow() and PermissionSQL.deny() shortcuts Closes #2556 * Filter out temp database from attached_databases() Refs https://github.com/simonw/datasette/issues/2557#issuecomment-3470510837
This commit is contained in:
parent
5247856bd4
commit
6a71bde37f
14 changed files with 241 additions and 227 deletions
|
|
@ -28,14 +28,7 @@ async def permission_resources_sql(datasette, actor, action):
|
|||
# Add a single global-level allow rule (NULL, NULL) for root
|
||||
# This allows root to access everything by default, but database-level
|
||||
# and table-level deny rules in config can still block specific resources
|
||||
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'root user' AS reason"
|
||||
rules.append(
|
||||
PermissionSQL(
|
||||
source="root_permissions",
|
||||
sql=sql,
|
||||
params={},
|
||||
)
|
||||
)
|
||||
rules.append(PermissionSQL.allow(reason="root user"))
|
||||
|
||||
# 3. Config-based permission rules
|
||||
config_rules = await _config_permission_rules(datasette, actor, action)
|
||||
|
|
@ -44,14 +37,7 @@ async def permission_resources_sql(datasette, actor, action):
|
|||
# 4. Check default_allow_sql setting for execute-sql action
|
||||
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
|
||||
# Return a deny rule for all databases
|
||||
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, 'default_allow_sql is false' AS reason"
|
||||
rules.append(
|
||||
PermissionSQL(
|
||||
source="default_allow_sql_setting",
|
||||
sql=sql,
|
||||
params={},
|
||||
)
|
||||
)
|
||||
rules.append(PermissionSQL.deny(reason="default_allow_sql is false"))
|
||||
# Early return - don't add default allow rule
|
||||
if not rules:
|
||||
return None
|
||||
|
|
@ -73,17 +59,7 @@ async def permission_resources_sql(datasette, actor, action):
|
|||
}
|
||||
if action in default_allow_actions:
|
||||
reason = f"default allow for {action}".replace("'", "''")
|
||||
sql = (
|
||||
"SELECT NULL AS parent, NULL AS child, 1 AS allow, "
|
||||
f"'{reason}' AS reason"
|
||||
)
|
||||
rules.append(
|
||||
PermissionSQL(
|
||||
source="default_permissions",
|
||||
sql=sql,
|
||||
params={},
|
||||
)
|
||||
)
|
||||
rules.append(PermissionSQL.allow(reason=reason))
|
||||
|
||||
if not rules:
|
||||
return None
|
||||
|
|
@ -286,7 +262,7 @@ async def _config_permission_rules(datasette, actor, action) -> list[PermissionS
|
|||
params[f"{key}_reason"] = reason
|
||||
|
||||
sql = "\nUNION ALL\n".join(parts)
|
||||
return [PermissionSQL(source="config_permissions", sql=sql, params=params)]
|
||||
return [PermissionSQL(sql=sql, params=params)]
|
||||
|
||||
|
||||
async def _restriction_permission_rules(
|
||||
|
|
@ -343,7 +319,6 @@ async def _restriction_permission_rules(
|
|||
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, :deny_reason AS reason"
|
||||
return [
|
||||
PermissionSQL(
|
||||
source="actor_restrictions",
|
||||
sql=sql,
|
||||
params={
|
||||
"deny_reason": f"actor restrictions: {action} not in allowlist"
|
||||
|
|
@ -402,7 +377,7 @@ async def _restriction_permission_rules(
|
|||
|
||||
sql = "\nUNION ALL\n".join(selects)
|
||||
|
||||
return [PermissionSQL(source="actor_restrictions", sql=sql, params=params)]
|
||||
return [PermissionSQL(sql=sql, params=params)]
|
||||
|
||||
|
||||
def restrictions_allow_action(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, NamedTuple
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
|
||||
class Resource(ABC):
|
||||
|
|
@ -79,6 +79,9 @@ class Action:
|
|||
also_requires: str | None = None # Optional action name that must also be allowed
|
||||
|
||||
|
||||
_reason_id = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionSQL:
|
||||
"""
|
||||
|
|
@ -89,9 +92,25 @@ class PermissionSQL:
|
|||
reason TEXT
|
||||
"""
|
||||
|
||||
source: str # identifier used for auditing (e.g., plugin name)
|
||||
sql: str # SQL that SELECTs the 4 columns above
|
||||
params: Dict[str, Any] # bound params for the SQL (values only; no ':' prefix)
|
||||
params: dict[str, Any] | None = (
|
||||
None # bound params for the SQL (values only; no ':' prefix)
|
||||
)
|
||||
source: str | None = None # System will set this to the plugin name
|
||||
|
||||
@classmethod
|
||||
def allow(cls, reason: str, _allow: bool = True) -> "PermissionSQL":
|
||||
global _reason_id
|
||||
i = _reason_id
|
||||
_reason_id += 1
|
||||
return cls(
|
||||
sql=f"SELECT NULL AS parent, NULL AS child, {1 if _allow else 0} AS allow, :reason_{i} AS reason",
|
||||
params={f"reason_{i}": reason},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def deny(cls, reason: str) -> "PermissionSQL":
|
||||
return cls.allow(reason=reason, _allow=False)
|
||||
|
||||
|
||||
# This is obsolete, replaced by Action and ResourceType
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ function displayResults(data) {
|
|||
html += '<th>Resource Path</th>';
|
||||
html += '<th>Parent</th>';
|
||||
html += '<th>Child</th>';
|
||||
html += '<th>Source Plugin</th>';
|
||||
html += '<th>Reason</th>';
|
||||
html += '</tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
|
@ -152,6 +153,7 @@ function displayResults(data) {
|
|||
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.reason || '—')}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,42 +23,12 @@ The core pattern is:
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from datasette.plugins import pm
|
||||
from datasette.utils import await_me_maybe
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.utils.permissions import gather_permission_sql_from_hooks
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
|
||||
def _process_permission_results(results) -> tuple[list[str], dict]:
|
||||
"""
|
||||
Process plugin permission results into SQL fragments and parameters.
|
||||
|
||||
Args:
|
||||
results: Results from permission_resources_sql hook (may be list or single PermissionSQL)
|
||||
|
||||
Returns:
|
||||
A tuple of (list of SQL strings, dict of parameters)
|
||||
"""
|
||||
rule_sqls = []
|
||||
all_params = {}
|
||||
|
||||
if results is None:
|
||||
return rule_sqls, all_params
|
||||
|
||||
if isinstance(results, list):
|
||||
for plugin_sql in results:
|
||||
if isinstance(plugin_sql, PermissionSQL):
|
||||
rule_sqls.append(plugin_sql.sql)
|
||||
all_params.update(plugin_sql.params)
|
||||
elif isinstance(results, PermissionSQL):
|
||||
rule_sqls.append(results.sql)
|
||||
all_params.update(results.params)
|
||||
|
||||
return rule_sqls, all_params
|
||||
|
||||
|
||||
async def build_allowed_resources_sql(
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
|
|
@ -179,22 +149,24 @@ async def _build_single_action_sql(
|
|||
# Get base resources SQL from the resource class
|
||||
base_resources_sql = await action_obj.resource_class.resources_sql(datasette)
|
||||
|
||||
# Get all permission rule fragments from plugins via the hook
|
||||
rule_results = pm.hook.permission_resources_sql(
|
||||
permission_sqls = await gather_permission_sql_from_hooks(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
action=action,
|
||||
)
|
||||
|
||||
# Combine rule fragments and collect parameters
|
||||
all_params = {}
|
||||
rule_sqls = []
|
||||
|
||||
for result in rule_results:
|
||||
result = await await_me_maybe(result)
|
||||
sqls, params = _process_permission_results(result)
|
||||
rule_sqls.extend(sqls)
|
||||
all_params.update(params)
|
||||
for permission_sql in permission_sqls:
|
||||
rule_sqls.append(
|
||||
f"""
|
||||
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
|
||||
{permission_sql.sql}
|
||||
)
|
||||
""".strip()
|
||||
)
|
||||
all_params.update(permission_sql.params or {})
|
||||
|
||||
# If no rules, return empty result (deny all)
|
||||
if not rule_sqls:
|
||||
|
|
@ -219,28 +191,21 @@ async def _build_single_action_sql(
|
|||
|
||||
# If include_is_private, we need to build anonymous permissions too
|
||||
if include_is_private:
|
||||
# Get anonymous permission rules
|
||||
anon_rule_results = pm.hook.permission_resources_sql(
|
||||
anon_permission_sqls = await gather_permission_sql_from_hooks(
|
||||
datasette=datasette,
|
||||
actor=None,
|
||||
action=action,
|
||||
)
|
||||
anon_rule_sqls = []
|
||||
anon_params = {}
|
||||
for result in anon_rule_results:
|
||||
result = await await_me_maybe(result)
|
||||
sqls, params = _process_permission_results(result)
|
||||
anon_rule_sqls.extend(sqls)
|
||||
# Namespace anonymous params to avoid conflicts
|
||||
for key, value in params.items():
|
||||
anon_params[f"anon_{key}"] = value
|
||||
|
||||
# Rewrite anonymous SQL to use namespaced params
|
||||
anon_sqls_rewritten = []
|
||||
for sql in anon_rule_sqls:
|
||||
for key in params.keys():
|
||||
sql = sql.replace(f":{key}", f":anon_{key}")
|
||||
anon_sqls_rewritten.append(sql)
|
||||
anon_params = {}
|
||||
|
||||
for permission_sql in anon_permission_sqls:
|
||||
rewritten_sql = permission_sql.sql
|
||||
for key, value in (permission_sql.params or {}).items():
|
||||
anon_key = f"anon_{key}"
|
||||
anon_params[anon_key] = value
|
||||
rewritten_sql = rewritten_sql.replace(f":{key}", f":{anon_key}")
|
||||
anon_sqls_rewritten.append(rewritten_sql)
|
||||
|
||||
all_params.update(anon_params)
|
||||
|
||||
|
|
@ -261,8 +226,8 @@ async def _build_single_action_sql(
|
|||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.reason END) AS allow_reasons",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||
" GROUP BY b.parent, b.child",
|
||||
|
|
@ -271,8 +236,8 @@ async def _build_single_action_sql(
|
|||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.reason END) AS allow_reasons",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
|
|
@ -281,8 +246,8 @@ async def _build_single_action_sql(
|
|||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.reason END) AS allow_reasons",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
|
|
@ -430,32 +395,31 @@ async def build_permission_rules_sql(
|
|||
if not action_obj:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
# Get all permission rule fragments from plugins via the hook
|
||||
rule_results = pm.hook.permission_resources_sql(
|
||||
permission_sqls = await gather_permission_sql_from_hooks(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
action=action,
|
||||
)
|
||||
|
||||
# Combine rule fragments and collect parameters
|
||||
all_params = {}
|
||||
rule_sqls = []
|
||||
|
||||
for result in rule_results:
|
||||
result = await await_me_maybe(result)
|
||||
sqls, params = _process_permission_results(result)
|
||||
rule_sqls.extend(sqls)
|
||||
all_params.update(params)
|
||||
|
||||
# Build the UNION query
|
||||
if not rule_sqls:
|
||||
# Return empty result set
|
||||
if not permission_sqls:
|
||||
return (
|
||||
"SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason, NULL AS source_plugin WHERE 0",
|
||||
{},
|
||||
)
|
||||
|
||||
rules_union = " UNION ALL ".join(rule_sqls)
|
||||
union_parts = []
|
||||
all_params = {}
|
||||
for permission_sql in permission_sqls:
|
||||
union_parts.append(
|
||||
f"""
|
||||
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
|
||||
{permission_sql.sql}
|
||||
)
|
||||
""".strip()
|
||||
)
|
||||
all_params.update(permission_sql.params or {})
|
||||
|
||||
rules_union = " UNION ALL ".join(union_parts)
|
||||
return rules_union, all_params
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,69 @@ from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
|||
import sqlite3
|
||||
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.plugins import pm
|
||||
from datasette.utils import await_me_maybe
|
||||
|
||||
|
||||
async def gather_permission_sql_from_hooks(
|
||||
*, datasette, actor: dict | None, action: str
|
||||
) -> List[PermissionSQL]:
|
||||
"""Collect PermissionSQL objects from the permission_resources_sql hook.
|
||||
|
||||
Ensures that each returned PermissionSQL has a populated ``source``.
|
||||
"""
|
||||
|
||||
hook_caller = pm.hook.permission_resources_sql
|
||||
hookimpls = hook_caller.get_hookimpls()
|
||||
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
|
||||
|
||||
collected: List[PermissionSQL] = []
|
||||
actor_json = json.dumps(actor) if actor is not None else None
|
||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||
|
||||
for index, result in enumerate(hook_results):
|
||||
hookimpl = hookimpls[index]
|
||||
resolved = await await_me_maybe(result)
|
||||
default_source = _plugin_name_from_hookimpl(hookimpl)
|
||||
for permission_sql in _iter_permission_sql_from_result(resolved, action=action):
|
||||
if not permission_sql.source:
|
||||
permission_sql.source = default_source
|
||||
params = permission_sql.params or {}
|
||||
params.setdefault("action", action)
|
||||
params.setdefault("actor", actor_json)
|
||||
params.setdefault("actor_id", actor_id)
|
||||
collected.append(permission_sql)
|
||||
|
||||
return collected
|
||||
|
||||
|
||||
def _plugin_name_from_hookimpl(hookimpl) -> str:
|
||||
if getattr(hookimpl, "plugin_name", None):
|
||||
return hookimpl.plugin_name
|
||||
plugin = getattr(hookimpl, "plugin", None)
|
||||
if hasattr(plugin, "__name__"):
|
||||
return plugin.__name__
|
||||
return repr(plugin)
|
||||
|
||||
|
||||
def _iter_permission_sql_from_result(
|
||||
result: Any, *, action: str
|
||||
) -> Iterable[PermissionSQL]:
|
||||
if result is None:
|
||||
return []
|
||||
if isinstance(result, PermissionSQL):
|
||||
return [result]
|
||||
if isinstance(result, (list, tuple)):
|
||||
collected: List[PermissionSQL] = []
|
||||
for item in result:
|
||||
collected.extend(_iter_permission_sql_from_result(item, action=action))
|
||||
return collected
|
||||
if callable(result):
|
||||
permission_sql = result(action) # type: ignore[call-arg]
|
||||
return _iter_permission_sql_from_result(permission_sql, action=action)
|
||||
raise TypeError(
|
||||
"Plugin providers must return PermissionSQL instances, sequences, or callables"
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------
|
||||
|
|
@ -34,7 +97,7 @@ def build_rules_union(
|
|||
|
||||
for p in plugins:
|
||||
# No namespacing - just use plugin params as-is
|
||||
params.update(p.params)
|
||||
params.update(p.params or {})
|
||||
|
||||
parts.append(
|
||||
f"""
|
||||
|
|
|
|||
|
|
@ -444,7 +444,7 @@ class PermissionRulesView(BaseView):
|
|||
WITH rules AS (
|
||||
{union_sql}
|
||||
)
|
||||
SELECT parent, child, allow, reason
|
||||
SELECT parent, child, allow, reason, source_plugin
|
||||
FROM rules
|
||||
ORDER BY allow DESC, (parent IS NOT NULL), parent, child
|
||||
LIMIT :limit OFFSET :offset
|
||||
|
|
@ -463,6 +463,7 @@ class PermissionRulesView(BaseView):
|
|||
"resource": _resource_path(parent, child),
|
||||
"allow": row["allow"],
|
||||
"reason": row["reason"],
|
||||
"source_plugin": row["source_plugin"],
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue