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
|
# Add a single global-level allow rule (NULL, NULL) for root
|
||||||
# This allows root to access everything by default, but database-level
|
# This allows root to access everything by default, but database-level
|
||||||
# and table-level deny rules in config can still block specific resources
|
# 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.allow(reason="root user"))
|
||||||
rules.append(
|
|
||||||
PermissionSQL(
|
|
||||||
source="root_permissions",
|
|
||||||
sql=sql,
|
|
||||||
params={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Config-based permission rules
|
# 3. Config-based permission rules
|
||||||
config_rules = await _config_permission_rules(datasette, actor, action)
|
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
|
# 4. Check default_allow_sql setting for execute-sql action
|
||||||
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
|
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
|
||||||
# Return a deny rule for all databases
|
# 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.deny(reason="default_allow_sql is false"))
|
||||||
rules.append(
|
|
||||||
PermissionSQL(
|
|
||||||
source="default_allow_sql_setting",
|
|
||||||
sql=sql,
|
|
||||||
params={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Early return - don't add default allow rule
|
# Early return - don't add default allow rule
|
||||||
if not rules:
|
if not rules:
|
||||||
return None
|
return None
|
||||||
|
|
@ -73,17 +59,7 @@ async def permission_resources_sql(datasette, actor, action):
|
||||||
}
|
}
|
||||||
if action in default_allow_actions:
|
if action in default_allow_actions:
|
||||||
reason = f"default allow for {action}".replace("'", "''")
|
reason = f"default allow for {action}".replace("'", "''")
|
||||||
sql = (
|
rules.append(PermissionSQL.allow(reason=reason))
|
||||||
"SELECT NULL AS parent, NULL AS child, 1 AS allow, "
|
|
||||||
f"'{reason}' AS reason"
|
|
||||||
)
|
|
||||||
rules.append(
|
|
||||||
PermissionSQL(
|
|
||||||
source="default_permissions",
|
|
||||||
sql=sql,
|
|
||||||
params={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not rules:
|
if not rules:
|
||||||
return None
|
return None
|
||||||
|
|
@ -286,7 +262,7 @@ async def _config_permission_rules(datasette, actor, action) -> list[PermissionS
|
||||||
params[f"{key}_reason"] = reason
|
params[f"{key}_reason"] = reason
|
||||||
|
|
||||||
sql = "\nUNION ALL\n".join(parts)
|
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(
|
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"
|
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, :deny_reason AS reason"
|
||||||
return [
|
return [
|
||||||
PermissionSQL(
|
PermissionSQL(
|
||||||
source="actor_restrictions",
|
|
||||||
sql=sql,
|
sql=sql,
|
||||||
params={
|
params={
|
||||||
"deny_reason": f"actor restrictions: {action} not in allowlist"
|
"deny_reason": f"actor restrictions: {action} not in allowlist"
|
||||||
|
|
@ -402,7 +377,7 @@ async def _restriction_permission_rules(
|
||||||
|
|
||||||
sql = "\nUNION ALL\n".join(selects)
|
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(
|
def restrictions_allow_action(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
|
|
||||||
class Resource(ABC):
|
class Resource(ABC):
|
||||||
|
|
@ -79,6 +79,9 @@ class Action:
|
||||||
also_requires: str | None = None # Optional action name that must also be allowed
|
also_requires: str | None = None # Optional action name that must also be allowed
|
||||||
|
|
||||||
|
|
||||||
|
_reason_id = 1
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PermissionSQL:
|
class PermissionSQL:
|
||||||
"""
|
"""
|
||||||
|
|
@ -89,9 +92,25 @@ class PermissionSQL:
|
||||||
reason TEXT
|
reason TEXT
|
||||||
"""
|
"""
|
||||||
|
|
||||||
source: str # identifier used for auditing (e.g., plugin name)
|
|
||||||
sql: str # SQL that SELECTs the 4 columns above
|
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
|
# This is obsolete, replaced by Action and ResourceType
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ function displayResults(data) {
|
||||||
html += '<th>Resource Path</th>';
|
html += '<th>Resource Path</th>';
|
||||||
html += '<th>Parent</th>';
|
html += '<th>Parent</th>';
|
||||||
html += '<th>Child</th>';
|
html += '<th>Child</th>';
|
||||||
|
html += '<th>Source Plugin</th>';
|
||||||
html += '<th>Reason</th>';
|
html += '<th>Reason</th>';
|
||||||
html += '</tr></thead>';
|
html += '</tr></thead>';
|
||||||
html += '<tbody>';
|
html += '<tbody>';
|
||||||
|
|
@ -152,6 +153,7 @@ function displayResults(data) {
|
||||||
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||||
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||||
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;
|
||||||
html += `<td>${escapeHtml(item.reason || '—')}</td>`;
|
html += `<td>${escapeHtml(item.reason || '—')}</td>`;
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,42 +23,12 @@ The core pattern is:
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from datasette.plugins import pm
|
from datasette.utils.permissions import gather_permission_sql_from_hooks
|
||||||
from datasette.utils import await_me_maybe
|
|
||||||
from datasette.permissions import PermissionSQL
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
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(
|
async def build_allowed_resources_sql(
|
||||||
datasette: "Datasette",
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: dict | None,
|
||||||
|
|
@ -179,22 +149,24 @@ async def _build_single_action_sql(
|
||||||
# Get base resources SQL from the resource class
|
# Get base resources SQL from the resource class
|
||||||
base_resources_sql = await action_obj.resource_class.resources_sql(datasette)
|
base_resources_sql = await action_obj.resource_class.resources_sql(datasette)
|
||||||
|
|
||||||
# Get all permission rule fragments from plugins via the hook
|
permission_sqls = await gather_permission_sql_from_hooks(
|
||||||
rule_results = pm.hook.permission_resources_sql(
|
|
||||||
datasette=datasette,
|
datasette=datasette,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
action=action,
|
action=action,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Combine rule fragments and collect parameters
|
|
||||||
all_params = {}
|
all_params = {}
|
||||||
rule_sqls = []
|
rule_sqls = []
|
||||||
|
|
||||||
for result in rule_results:
|
for permission_sql in permission_sqls:
|
||||||
result = await await_me_maybe(result)
|
rule_sqls.append(
|
||||||
sqls, params = _process_permission_results(result)
|
f"""
|
||||||
rule_sqls.extend(sqls)
|
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
|
||||||
all_params.update(params)
|
{permission_sql.sql}
|
||||||
|
)
|
||||||
|
""".strip()
|
||||||
|
)
|
||||||
|
all_params.update(permission_sql.params or {})
|
||||||
|
|
||||||
# If no rules, return empty result (deny all)
|
# If no rules, return empty result (deny all)
|
||||||
if not rule_sqls:
|
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, we need to build anonymous permissions too
|
||||||
if include_is_private:
|
if include_is_private:
|
||||||
# Get anonymous permission rules
|
anon_permission_sqls = await gather_permission_sql_from_hooks(
|
||||||
anon_rule_results = pm.hook.permission_resources_sql(
|
|
||||||
datasette=datasette,
|
datasette=datasette,
|
||||||
actor=None,
|
actor=None,
|
||||||
action=action,
|
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 = []
|
anon_sqls_rewritten = []
|
||||||
for sql in anon_rule_sqls:
|
anon_params = {}
|
||||||
for key in params.keys():
|
|
||||||
sql = sql.replace(f":{key}", f":anon_{key}")
|
for permission_sql in anon_permission_sqls:
|
||||||
anon_sqls_rewritten.append(sql)
|
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)
|
all_params.update(anon_params)
|
||||||
|
|
||||||
|
|
@ -261,8 +226,8 @@ async def _build_single_action_sql(
|
||||||
" SELECT b.parent, b.child,",
|
" SELECT b.parent, b.child,",
|
||||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
" 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,",
|
" 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 = 0 THEN ar.source_plugin || ': ' || 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 = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||||
" FROM base b",
|
" FROM base b",
|
||||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||||
" GROUP BY b.parent, b.child",
|
" GROUP BY b.parent, b.child",
|
||||||
|
|
@ -271,8 +236,8 @@ async def _build_single_action_sql(
|
||||||
" SELECT b.parent, b.child,",
|
" SELECT b.parent, b.child,",
|
||||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
" 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,",
|
" 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 = 0 THEN ar.source_plugin || ': ' || 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 = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||||
" FROM base b",
|
" FROM base b",
|
||||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||||
" GROUP BY b.parent, b.child",
|
" GROUP BY b.parent, b.child",
|
||||||
|
|
@ -281,8 +246,8 @@ async def _build_single_action_sql(
|
||||||
" SELECT b.parent, b.child,",
|
" SELECT b.parent, b.child,",
|
||||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
" 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,",
|
" 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 = 0 THEN ar.source_plugin || ': ' || 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 = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||||
" FROM base b",
|
" FROM base b",
|
||||||
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||||
" GROUP BY b.parent, b.child",
|
" GROUP BY b.parent, b.child",
|
||||||
|
|
@ -430,32 +395,31 @@ async def build_permission_rules_sql(
|
||||||
if not action_obj:
|
if not action_obj:
|
||||||
raise ValueError(f"Unknown action: {action}")
|
raise ValueError(f"Unknown action: {action}")
|
||||||
|
|
||||||
# Get all permission rule fragments from plugins via the hook
|
permission_sqls = await gather_permission_sql_from_hooks(
|
||||||
rule_results = pm.hook.permission_resources_sql(
|
|
||||||
datasette=datasette,
|
datasette=datasette,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
action=action,
|
action=action,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Combine rule fragments and collect parameters
|
if not permission_sqls:
|
||||||
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
|
|
||||||
return (
|
return (
|
||||||
"SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason, NULL AS source_plugin WHERE 0",
|
"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
|
return rules_union, all_params
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,69 @@ from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from datasette.permissions import PermissionSQL
|
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:
|
for p in plugins:
|
||||||
# No namespacing - just use plugin params as-is
|
# No namespacing - just use plugin params as-is
|
||||||
params.update(p.params)
|
params.update(p.params or {})
|
||||||
|
|
||||||
parts.append(
|
parts.append(
|
||||||
f"""
|
f"""
|
||||||
|
|
|
||||||
|
|
@ -444,7 +444,7 @@ class PermissionRulesView(BaseView):
|
||||||
WITH rules AS (
|
WITH rules AS (
|
||||||
{union_sql}
|
{union_sql}
|
||||||
)
|
)
|
||||||
SELECT parent, child, allow, reason
|
SELECT parent, child, allow, reason, source_plugin
|
||||||
FROM rules
|
FROM rules
|
||||||
ORDER BY allow DESC, (parent IS NOT NULL), parent, child
|
ORDER BY allow DESC, (parent IS NOT NULL), parent, child
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
|
|
@ -463,6 +463,7 @@ class PermissionRulesView(BaseView):
|
||||||
"resource": _resource_path(parent, child),
|
"resource": _resource_path(parent, child),
|
||||||
"allow": row["allow"],
|
"allow": row["allow"],
|
||||||
"reason": row["reason"],
|
"reason": row["reason"],
|
||||||
|
"source_plugin": row["source_plugin"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1457,17 +1457,28 @@ permission_resources_sql(datasette, actor, action)
|
||||||
The permission action being evaluated. Examples include ``"view-table"`` or ``"insert-row"``.
|
The permission action being evaluated. Examples include ``"view-table"`` or ``"insert-row"``.
|
||||||
|
|
||||||
Return value
|
Return value
|
||||||
A :class:`datasette.utils.permissions.PluginSQL` object, ``None`` or an iterable of ``PluginSQL`` objects.
|
A :class:`datasette.permissions.PermissionSQL` object, ``None`` or an iterable of ``PermissionSQL`` objects.
|
||||||
|
|
||||||
Datasette's action-based permission resolver calls this hook to gather SQL rows describing which
|
Datasette's action-based permission resolver calls this hook to gather SQL rows describing which
|
||||||
resources an actor may access (``allow = 1``) or should be denied (``allow = 0``) for a specific action.
|
resources an actor may access (``allow = 1``) or should be denied (``allow = 0``) for a specific action.
|
||||||
Each SQL snippet should return ``parent``, ``child``, ``allow`` and ``reason`` columns.
|
Each SQL snippet should return ``parent``, ``child``, ``allow`` and ``reason`` columns.
|
||||||
|
|
||||||
**Parameter naming convention:** Plugin parameters in ``PluginSQL.params`` should use unique names
|
**Parameter naming convention:** Plugin parameters in ``PermissionSQL.params`` should use unique names
|
||||||
to avoid conflicts with other plugins. The recommended convention is to prefix parameters with your
|
to avoid conflicts with other plugins. The recommended convention is to prefix parameters with your
|
||||||
plugin's source name (e.g., ``myplugin_user_id``). The system reserves these parameter names:
|
plugin's source name (e.g., ``myplugin_user_id``). The system reserves these parameter names:
|
||||||
``:actor``, ``:actor_id``, ``:action``, and ``:filter_parent``.
|
``:actor``, ``:actor_id``, ``:action``, and ``:filter_parent``.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
NULL AS parent,
|
||||||
|
NULL AS child,
|
||||||
|
1 AS allow,
|
||||||
|
'reason goes here' AS reason
|
||||||
|
|
||||||
|
Or ``0 AS allow`` for denies.
|
||||||
|
|
||||||
Permission plugin examples
|
Permission plugin examples
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
@ -1475,7 +1486,7 @@ Permission plugin examples
|
||||||
These snippets show how to use the new ``permission_resources_sql`` hook to
|
These snippets show how to use the new ``permission_resources_sql`` hook to
|
||||||
contribute rows to the action-based permission resolver. Each hook receives the
|
contribute rows to the action-based permission resolver. Each hook receives the
|
||||||
current actor dictionary (or ``None``) and must return ``None`` or an instance or list of
|
current actor dictionary (or ``None``) and must return ``None`` or an instance or list of
|
||||||
``datasette.utils.permissions.PluginSQL`` (or a coroutine that resolves to that).
|
``datasette.permissions.PermissionSQL`` (or a coroutine that resolves to that).
|
||||||
|
|
||||||
Allow Alice to view a specific table
|
Allow Alice to view a specific table
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
@ -1486,7 +1497,7 @@ This plugin grants the actor with ``id == "alice"`` permission to perform the
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.utils.permissions import PluginSQL
|
from datasette.permissions import PermissionSQL
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -1496,8 +1507,7 @@ This plugin grants the actor with ``id == "alice"`` permission to perform the
|
||||||
if not actor or actor.get("id") != "alice":
|
if not actor or actor.get("id") != "alice":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return PluginSQL(
|
return PermissionSQL(
|
||||||
source="alice_sales_allow",
|
|
||||||
sql="""
|
sql="""
|
||||||
SELECT
|
SELECT
|
||||||
'accounting' AS parent,
|
'accounting' AS parent,
|
||||||
|
|
@ -1505,7 +1515,6 @@ This plugin grants the actor with ``id == "alice"`` permission to perform the
|
||||||
1 AS allow,
|
1 AS allow,
|
||||||
'alice can view accounting/sales' AS reason
|
'alice can view accounting/sales' AS reason
|
||||||
""",
|
""",
|
||||||
params={},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Restrict execute-sql to a database prefix
|
Restrict execute-sql to a database prefix
|
||||||
|
|
@ -1518,7 +1527,7 @@ will pass through to the SQL snippet.
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.utils.permissions import PluginSQL
|
from datasette.permissions import PermissionSQL
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -1526,8 +1535,7 @@ will pass through to the SQL snippet.
|
||||||
if action != "execute-sql":
|
if action != "execute-sql":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return PluginSQL(
|
return PermissionSQL(
|
||||||
source="analytics_execute_sql",
|
|
||||||
sql="""
|
sql="""
|
||||||
SELECT
|
SELECT
|
||||||
parent,
|
parent,
|
||||||
|
|
@ -1551,7 +1559,7 @@ with columns ``(actor_id, action, parent, child, allow, reason)``.
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.utils.permissions import PluginSQL
|
from datasette.permissions import PermissionSQL
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -1559,8 +1567,7 @@ with columns ``(actor_id, action, parent, child, allow, reason)``.
|
||||||
if not actor:
|
if not actor:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return PluginSQL(
|
return PermissionSQL(
|
||||||
source="permission_grants_table",
|
|
||||||
sql="""
|
sql="""
|
||||||
SELECT
|
SELECT
|
||||||
parent,
|
parent,
|
||||||
|
|
@ -1586,7 +1593,7 @@ The resolver will automatically apply the most specific rule.
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.utils.permissions import PluginSQL
|
from datasette.permissions import PermissionSQL
|
||||||
|
|
||||||
|
|
||||||
TRUSTED = {"alice", "bob"}
|
TRUSTED = {"alice", "bob"}
|
||||||
|
|
@ -1600,17 +1607,14 @@ The resolver will automatically apply the most specific rule.
|
||||||
actor_id = (actor or {}).get("id")
|
actor_id = (actor or {}).get("id")
|
||||||
|
|
||||||
if actor_id not in TRUSTED:
|
if actor_id not in TRUSTED:
|
||||||
return PluginSQL(
|
return PermissionSQL(
|
||||||
source="view_table_root_deny",
|
|
||||||
sql="""
|
sql="""
|
||||||
SELECT NULL AS parent, NULL AS child, 0 AS allow,
|
SELECT NULL AS parent, NULL AS child, 0 AS allow,
|
||||||
'default deny view-table' AS reason
|
'default deny view-table' AS reason
|
||||||
""",
|
""",
|
||||||
params={},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return PluginSQL(
|
return PermissionSQL(
|
||||||
source="trusted_allow",
|
|
||||||
sql="""
|
sql="""
|
||||||
SELECT NULL AS parent, NULL AS child, 0 AS allow,
|
SELECT NULL AS parent, NULL AS child, 0 AS allow,
|
||||||
'default deny view-table' AS reason
|
'default deny view-table' AS reason
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,39 @@ def register_actions(datasette):
|
||||||
takes_child=False,
|
takes_child=False,
|
||||||
resource_class=DatabaseResource,
|
resource_class=DatabaseResource,
|
||||||
),
|
),
|
||||||
|
# Test actions for test_hook_permission_allowed
|
||||||
|
Action(
|
||||||
|
name="this_is_allowed",
|
||||||
|
abbr=None,
|
||||||
|
description=None,
|
||||||
|
takes_parent=False,
|
||||||
|
takes_child=False,
|
||||||
|
resource_class=InstanceResource,
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="this_is_denied",
|
||||||
|
abbr=None,
|
||||||
|
description=None,
|
||||||
|
takes_parent=False,
|
||||||
|
takes_child=False,
|
||||||
|
resource_class=InstanceResource,
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="this_is_allowed_async",
|
||||||
|
abbr=None,
|
||||||
|
description=None,
|
||||||
|
takes_parent=False,
|
||||||
|
takes_child=False,
|
||||||
|
resource_class=InstanceResource,
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="this_is_denied_async",
|
||||||
|
abbr=None,
|
||||||
|
description=None,
|
||||||
|
takes_parent=False,
|
||||||
|
takes_child=False,
|
||||||
|
resource_class=InstanceResource,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Support old-style config for backwards compatibility
|
# Support old-style config for backwards compatibility
|
||||||
|
|
@ -526,30 +559,27 @@ def permission_resources_sql(datasette, actor, action):
|
||||||
|
|
||||||
# Handle test actions used in test_hook_permission_allowed
|
# Handle test actions used in test_hook_permission_allowed
|
||||||
if action == "this_is_allowed":
|
if action == "this_is_allowed":
|
||||||
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'test plugin allows this_is_allowed' AS reason"
|
return PermissionSQL.allow(reason="test plugin allows this_is_allowed")
|
||||||
return PermissionSQL(source="my_plugin", sql=sql, params={})
|
|
||||||
elif action == "this_is_denied":
|
elif action == "this_is_denied":
|
||||||
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, 'test plugin denies this_is_denied' AS reason"
|
return PermissionSQL.deny(reason="test plugin denies this_is_denied")
|
||||||
return PermissionSQL(source="my_plugin", sql=sql, params={})
|
|
||||||
elif action == "this_is_allowed_async":
|
elif action == "this_is_allowed_async":
|
||||||
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'test plugin allows this_is_allowed_async' AS reason"
|
return PermissionSQL.allow(reason="test plugin allows this_is_allowed_async")
|
||||||
return PermissionSQL(source="my_plugin", sql=sql, params={})
|
|
||||||
elif action == "this_is_denied_async":
|
elif action == "this_is_denied_async":
|
||||||
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, 'test plugin denies this_is_denied_async' AS reason"
|
return PermissionSQL.deny(reason="test plugin denies this_is_denied_async")
|
||||||
return PermissionSQL(source="my_plugin", sql=sql, params={})
|
|
||||||
elif action == "view-database-download":
|
elif action == "view-database-download":
|
||||||
# Return rule based on actor's can_download permission
|
# Return rule based on actor's can_download permission
|
||||||
if actor and actor.get("can_download"):
|
if actor and actor.get("can_download"):
|
||||||
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'actor has can_download' AS reason"
|
return PermissionSQL.allow(reason="actor has can_download")
|
||||||
else:
|
else:
|
||||||
return None # No opinion
|
return None # No opinion
|
||||||
return PermissionSQL(source="my_plugin", sql=sql, params={})
|
|
||||||
elif action == "view-database":
|
elif action == "view-database":
|
||||||
# Also grant view-database if actor has can_download (needed for download to work)
|
# Also grant view-database if actor has can_download (needed for download to work)
|
||||||
if actor and actor.get("can_download"):
|
if actor and actor.get("can_download"):
|
||||||
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'actor has can_download, grants view-database' AS reason"
|
return PermissionSQL.allow(
|
||||||
return PermissionSQL(source="my_plugin", sql=sql, params={})
|
reason="actor has can_download, grants view-database"
|
||||||
return None
|
)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
elif action in (
|
elif action in (
|
||||||
"insert-row",
|
"insert-row",
|
||||||
"create-table",
|
"create-table",
|
||||||
|
|
@ -560,7 +590,6 @@ def permission_resources_sql(datasette, actor, action):
|
||||||
# Special permissions for latest.datasette.io demos
|
# Special permissions for latest.datasette.io demos
|
||||||
actor_id = actor.get("id") if actor else None
|
actor_id = actor.get("id") if actor else None
|
||||||
if actor_id == "todomvc":
|
if actor_id == "todomvc":
|
||||||
sql = f"SELECT NULL AS parent, NULL AS child, 1 AS allow, 'todomvc actor allowed for {action}' AS reason"
|
return PermissionSQL.allow(reason=f"todomvc actor allowed for {action}")
|
||||||
return PermissionSQL(source="my_plugin", sql=sql, params={})
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ async def test_allowed_resources_global_allow(test_ds):
|
||||||
def rules_callback(datasette, actor, action):
|
def rules_callback(datasette, actor, action):
|
||||||
if actor and actor.get("id") == "alice":
|
if actor and actor.get("id") == "alice":
|
||||||
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global: alice has access' AS reason"
|
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global: alice has access' AS reason"
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
@ -101,7 +101,7 @@ async def test_allowed_specific_resource(test_ds):
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason
|
SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason
|
||||||
"""
|
"""
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
@ -145,7 +145,7 @@ async def test_allowed_resources_with_reasons(test_ds):
|
||||||
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,
|
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,
|
||||||
'child: sensitive data denied' AS reason
|
'child: sensitive data denied' AS reason
|
||||||
"""
|
"""
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
@ -186,7 +186,7 @@ async def test_child_deny_overrides_parent_allow(test_ds):
|
||||||
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,
|
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,
|
||||||
'child: deny sensitive' AS reason
|
'child: deny sensitive' AS reason
|
||||||
"""
|
"""
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
@ -234,7 +234,7 @@ async def test_child_allow_overrides_parent_deny(test_ds):
|
||||||
SELECT 'production' AS parent, 'orders' AS child, 1 AS allow,
|
SELECT 'production' AS parent, 'orders' AS child, 1 AS allow,
|
||||||
'child: carol can see orders' AS reason
|
'child: carol can see orders' AS reason
|
||||||
"""
|
"""
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
@ -283,7 +283,7 @@ async def test_sql_does_filtering_not_python(test_ds):
|
||||||
SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow,
|
SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow,
|
||||||
'specific allow' AS reason
|
'specific allow' AS reason
|
||||||
"""
|
"""
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
pm.register(plugin, name="test_plugin")
|
pm.register(plugin, name="test_plugin")
|
||||||
|
|
@ -338,13 +338,15 @@ async def test_no_permission_rules_returns_correct_schema():
|
||||||
)
|
)
|
||||||
await ds._refresh_schemas()
|
await ds._refresh_schemas()
|
||||||
|
|
||||||
# Temporarily block all permission_resources_sql hooks to simulate no rules
|
# Temporarily unregister all permission_resources_sql providers to simulate no rules
|
||||||
original_hook = pm.hook.permission_resources_sql
|
hook_caller = pm.hook.permission_resources_sql
|
||||||
|
hookimpls = hook_caller.get_hookimpls()
|
||||||
|
removed_plugins = [
|
||||||
|
(impl.plugin_name, impl.plugin) for impl in hookimpls if impl.plugin is not None
|
||||||
|
]
|
||||||
|
|
||||||
def empty_hook(*args, **kwargs):
|
for plugin_name, _ in removed_plugins:
|
||||||
return []
|
pm.unregister(name=plugin_name)
|
||||||
|
|
||||||
pm.hook.permission_resources_sql = empty_hook
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Call build_allowed_resources_sql directly which will hit the no-rules code path
|
# Call build_allowed_resources_sql directly which will hit the no-rules code path
|
||||||
|
|
@ -366,5 +368,6 @@ async def test_no_permission_rules_returns_correct_schema():
|
||||||
assert len(result.rows) == 0
|
assert len(result.rows) == 0
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Restore original hook
|
# Restore original plugins in the order they were removed
|
||||||
pm.hook.permission_resources_sql = original_hook
|
for plugin_name, plugin in removed_plugins:
|
||||||
|
pm.register(plugin, name=plugin_name)
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ async def test_tables_endpoint_global_access(test_ds):
|
||||||
def rules_callback(datasette, actor, action):
|
def rules_callback(datasette, actor, action):
|
||||||
if actor and actor.get("id") == "alice":
|
if actor and actor.get("id") == "alice":
|
||||||
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global: alice has access' AS reason"
|
sql = "SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global: alice has access' AS reason"
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
@ -98,7 +98,7 @@ async def test_tables_endpoint_database_restriction(test_ds):
|
||||||
if actor and actor.get("role") == "analyst":
|
if actor and actor.get("role") == "analyst":
|
||||||
# Allow only analytics database
|
# Allow only analytics database
|
||||||
sql = "SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason"
|
sql = "SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason"
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
@ -145,7 +145,7 @@ async def test_tables_endpoint_table_exception(test_ds):
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow, 'carol exception' AS reason
|
SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow, 'carol exception' AS reason
|
||||||
"""
|
"""
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
@ -187,7 +187,7 @@ async def test_tables_endpoint_deny_overrides_allow(test_ds):
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, 'deny sensitive' AS reason
|
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, 'deny sensitive' AS reason
|
||||||
"""
|
"""
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
@ -253,7 +253,7 @@ async def test_tables_endpoint_specific_table_only(test_ds):
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'production' AS parent, 'orders' AS child, 1 AS allow, 'specific table 2' AS reason
|
SELECT 'production' AS parent, 'orders' AS child, 1 AS allow, 'specific table 2' AS reason
|
||||||
"""
|
"""
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
@ -291,7 +291,7 @@ async def test_tables_endpoint_empty_result(test_ds):
|
||||||
if actor and actor.get("id") == "blocked":
|
if actor and actor.get("id") == "blocked":
|
||||||
# Global deny
|
# Global deny
|
||||||
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, 'global deny' AS reason"
|
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, 'global deny' AS reason"
|
||||||
return PermissionSQL(source="test", sql=sql, params={})
|
return PermissionSQL(sql=sql)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plugin = PermissionRulesPlugin(rules_callback)
|
plugin = PermissionRulesPlugin(rules_callback)
|
||||||
|
|
|
||||||
|
|
@ -453,16 +453,12 @@ async def test_execute_sql_requires_view_database():
|
||||||
if action == "execute-sql":
|
if action == "execute-sql":
|
||||||
# Grant execute-sql on the "secret" database
|
# Grant execute-sql on the "secret" database
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
source="test_plugin",
|
|
||||||
sql="SELECT 'secret' AS parent, NULL AS child, 1 AS allow, 'can execute sql' AS reason",
|
sql="SELECT 'secret' AS parent, NULL AS child, 1 AS allow, 'can execute sql' AS reason",
|
||||||
params={},
|
|
||||||
)
|
)
|
||||||
elif action == "view-database":
|
elif action == "view-database":
|
||||||
# Deny view-database on the "secret" database
|
# Deny view-database on the "secret" database
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
source="test_plugin",
|
|
||||||
sql="SELECT 'secret' AS parent, NULL AS child, 0 AS allow, 'cannot view db' AS reason",
|
sql="SELECT 'secret' AS parent, NULL AS child, 0 AS allow, 'cannot view db' AS reason",
|
||||||
params={},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,11 @@ async def test_plugin_config_file(ds_client):
|
||||||
)
|
)
|
||||||
def test_hook_extra_body_script(app_client, path, expected_extra_body_script):
|
def test_hook_extra_body_script(app_client, path, expected_extra_body_script):
|
||||||
r = re.compile(r"<script type=\"module\">var extra_body_script = (.*?);</script>")
|
r = re.compile(r"<script type=\"module\">var extra_body_script = (.*?);</script>")
|
||||||
json_data = r.search(app_client.get(path).text).group(1)
|
response = app_client.get(path)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
match = r.search(response.text)
|
||||||
|
assert match is not None, "No extra_body_script found in HTML"
|
||||||
|
json_data = match.group(1)
|
||||||
actual_data = json.loads(json_data)
|
actual_data = json.loads(json_data)
|
||||||
assert expected_extra_body_script == actual_data
|
assert expected_extra_body_script == actual_data
|
||||||
|
|
||||||
|
|
@ -673,39 +677,11 @@ async def test_existing_scope_actor_respected(ds_client):
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_hook_permission_allowed(action, expected):
|
async def test_hook_permission_allowed(action, expected):
|
||||||
from datasette.permissions import Action
|
# Test actions and permission logic are defined in tests/plugins/my_plugin.py
|
||||||
from datasette.resources import InstanceResource
|
ds = Datasette(plugins_dir=PLUGINS_DIR)
|
||||||
|
await ds.invoke_startup()
|
||||||
class TestPlugin:
|
actual = await ds.allowed(action=action, actor={"id": "actor"})
|
||||||
__name__ = "TestPlugin"
|
assert expected == actual
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def register_actions(self):
|
|
||||||
return [
|
|
||||||
Action(
|
|
||||||
name=name,
|
|
||||||
abbr=None,
|
|
||||||
description=None,
|
|
||||||
takes_parent=False,
|
|
||||||
takes_child=False,
|
|
||||||
resource_class=InstanceResource,
|
|
||||||
)
|
|
||||||
for name in (
|
|
||||||
"this_is_allowed",
|
|
||||||
"this_is_denied",
|
|
||||||
"this_is_allowed_async",
|
|
||||||
"this_is_denied_async",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
pm.register(TestPlugin(), name="undo_register_extras")
|
|
||||||
try:
|
|
||||||
ds = Datasette(plugins_dir=PLUGINS_DIR)
|
|
||||||
await ds.invoke_startup()
|
|
||||||
actual = await ds.allowed(action=action, actor={"id": "actor"})
|
|
||||||
assert expected == actual
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="undo_register_extras")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,7 @@ async def test_sortable_columns_metadata(ds_client):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.xfail
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"path,expected_rows",
|
"path,expected_rows",
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ def db():
|
||||||
|
|
||||||
path = tempfile.mktemp(suffix="demo.db")
|
path = tempfile.mktemp(suffix="demo.db")
|
||||||
db = ds.add_database(Database(ds, path=path))
|
db = ds.add_database(Database(ds, path=path))
|
||||||
print(path)
|
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,7 +24,6 @@ NO_RULES_SQL = (
|
||||||
def plugin_allow_all_for_user(user: str) -> Callable[[str], PermissionSQL]:
|
def plugin_allow_all_for_user(user: str) -> Callable[[str], PermissionSQL]:
|
||||||
def provider(action: str) -> PermissionSQL:
|
def provider(action: str) -> PermissionSQL:
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"allow_all",
|
|
||||||
"""
|
"""
|
||||||
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
||||||
'global allow for ' || :allow_all_user || ' on ' || :allow_all_action AS reason
|
'global allow for ' || :allow_all_user || ' on ' || :allow_all_action AS reason
|
||||||
|
|
@ -42,7 +40,6 @@ def plugin_deny_specific_table(
|
||||||
) -> Callable[[str], PermissionSQL]:
|
) -> Callable[[str], PermissionSQL]:
|
||||||
def provider(action: str) -> PermissionSQL:
|
def provider(action: str) -> PermissionSQL:
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"deny_specific_table",
|
|
||||||
"""
|
"""
|
||||||
SELECT :deny_specific_table_parent AS parent, :deny_specific_table_child AS child, 0 AS allow,
|
SELECT :deny_specific_table_parent AS parent, :deny_specific_table_child AS child, 0 AS allow,
|
||||||
'deny ' || :deny_specific_table_parent || '/' || :deny_specific_table_child || ' for ' || :deny_specific_table_user || ' on ' || :deny_specific_table_action AS reason
|
'deny ' || :deny_specific_table_parent || '/' || :deny_specific_table_child || ' for ' || :deny_specific_table_user || ' on ' || :deny_specific_table_action AS reason
|
||||||
|
|
@ -62,7 +59,6 @@ def plugin_deny_specific_table(
|
||||||
def plugin_org_policy_deny_parent(parent: str) -> Callable[[str], PermissionSQL]:
|
def plugin_org_policy_deny_parent(parent: str) -> Callable[[str], PermissionSQL]:
|
||||||
def provider(action: str) -> PermissionSQL:
|
def provider(action: str) -> PermissionSQL:
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"org_policy_parent_deny",
|
|
||||||
"""
|
"""
|
||||||
SELECT :org_policy_parent_deny_parent AS parent, NULL AS child, 0 AS allow,
|
SELECT :org_policy_parent_deny_parent AS parent, NULL AS child, 0 AS allow,
|
||||||
'org policy: parent ' || :org_policy_parent_deny_parent || ' denied on ' || :org_policy_parent_deny_action AS reason
|
'org policy: parent ' || :org_policy_parent_deny_parent || ' denied on ' || :org_policy_parent_deny_action AS reason
|
||||||
|
|
@ -81,7 +77,6 @@ def plugin_allow_parent_for_user(
|
||||||
) -> Callable[[str], PermissionSQL]:
|
) -> Callable[[str], PermissionSQL]:
|
||||||
def provider(action: str) -> PermissionSQL:
|
def provider(action: str) -> PermissionSQL:
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"allow_parent",
|
|
||||||
"""
|
"""
|
||||||
SELECT :allow_parent_parent AS parent, NULL AS child, 1 AS allow,
|
SELECT :allow_parent_parent AS parent, NULL AS child, 1 AS allow,
|
||||||
'allow full parent for ' || :allow_parent_user || ' on ' || :allow_parent_action AS reason
|
'allow full parent for ' || :allow_parent_user || ' on ' || :allow_parent_action AS reason
|
||||||
|
|
@ -102,7 +97,6 @@ def plugin_child_allow_for_user(
|
||||||
) -> Callable[[str], PermissionSQL]:
|
) -> Callable[[str], PermissionSQL]:
|
||||||
def provider(action: str) -> PermissionSQL:
|
def provider(action: str) -> PermissionSQL:
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"allow_child",
|
|
||||||
"""
|
"""
|
||||||
SELECT :allow_child_parent AS parent, :allow_child_child AS child, 1 AS allow,
|
SELECT :allow_child_parent AS parent, :allow_child_child AS child, 1 AS allow,
|
||||||
'allow child for ' || :allow_child_user || ' on ' || :allow_child_action AS reason
|
'allow child for ' || :allow_child_user || ' on ' || :allow_child_action AS reason
|
||||||
|
|
@ -122,7 +116,6 @@ def plugin_child_allow_for_user(
|
||||||
def plugin_root_deny_for_all() -> Callable[[str], PermissionSQL]:
|
def plugin_root_deny_for_all() -> Callable[[str], PermissionSQL]:
|
||||||
def provider(action: str) -> PermissionSQL:
|
def provider(action: str) -> PermissionSQL:
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"root_deny",
|
|
||||||
"""
|
"""
|
||||||
SELECT NULL AS parent, NULL AS child, 0 AS allow, 'root deny for all on ' || :root_deny_action AS reason
|
SELECT NULL AS parent, NULL AS child, 0 AS allow, 'root deny for all on ' || :root_deny_action AS reason
|
||||||
""",
|
""",
|
||||||
|
|
@ -137,7 +130,6 @@ def plugin_conflicting_same_child_rules(
|
||||||
) -> List[Callable[[str], PermissionSQL]]:
|
) -> List[Callable[[str], PermissionSQL]]:
|
||||||
def allow_provider(action: str) -> PermissionSQL:
|
def allow_provider(action: str) -> PermissionSQL:
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"conflict_child_allow",
|
|
||||||
"""
|
"""
|
||||||
SELECT :conflict_child_allow_parent AS parent, :conflict_child_allow_child AS child, 1 AS allow,
|
SELECT :conflict_child_allow_parent AS parent, :conflict_child_allow_child AS child, 1 AS allow,
|
||||||
'team grant at child for ' || :conflict_child_allow_user || ' on ' || :conflict_child_allow_action AS reason
|
'team grant at child for ' || :conflict_child_allow_user || ' on ' || :conflict_child_allow_action AS reason
|
||||||
|
|
@ -153,7 +145,6 @@ def plugin_conflicting_same_child_rules(
|
||||||
|
|
||||||
def deny_provider(action: str) -> PermissionSQL:
|
def deny_provider(action: str) -> PermissionSQL:
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"conflict_child_deny",
|
|
||||||
"""
|
"""
|
||||||
SELECT :conflict_child_deny_parent AS parent, :conflict_child_deny_child AS child, 0 AS allow,
|
SELECT :conflict_child_deny_parent AS parent, :conflict_child_deny_child AS child, 0 AS allow,
|
||||||
'exception deny at child for ' || :conflict_child_deny_user || ' on ' || :conflict_child_deny_action AS reason
|
'exception deny at child for ' || :conflict_child_deny_user || ' on ' || :conflict_child_deny_action AS reason
|
||||||
|
|
@ -175,16 +166,10 @@ def plugin_allow_all_for_action(
|
||||||
) -> Callable[[str], PermissionSQL]:
|
) -> Callable[[str], PermissionSQL]:
|
||||||
def provider(action: str) -> PermissionSQL:
|
def provider(action: str) -> PermissionSQL:
|
||||||
if action != allowed_action:
|
if action != allowed_action:
|
||||||
return PermissionSQL(
|
return PermissionSQL(NO_RULES_SQL)
|
||||||
f"allow_all_{allowed_action}_noop",
|
|
||||||
NO_RULES_SQL,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
source_name = f"allow_all_{allowed_action}"
|
|
||||||
# Sanitize parameter names by replacing hyphens with underscores
|
# Sanitize parameter names by replacing hyphens with underscores
|
||||||
param_prefix = source_name.replace("-", "_")
|
param_prefix = action.replace("-", "_")
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
source_name,
|
|
||||||
f"""
|
f"""
|
||||||
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
||||||
'global allow for ' || :{param_prefix}_user || ' on ' || :{param_prefix}_action AS reason
|
'global allow for ' || :{param_prefix}_user || ' on ' || :{param_prefix}_action AS reason
|
||||||
|
|
@ -513,7 +498,6 @@ async def test_actor_actor_id_action_parameters_available(db):
|
||||||
def plugin_using_all_parameters() -> Callable[[str], PermissionSQL]:
|
def plugin_using_all_parameters() -> Callable[[str], PermissionSQL]:
|
||||||
def provider(action: str) -> PermissionSQL:
|
def provider(action: str) -> PermissionSQL:
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"test_all_params",
|
|
||||||
"""
|
"""
|
||||||
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
||||||
'Actor ID: ' || COALESCE(:actor_id, 'null') ||
|
'Actor ID: ' || COALESCE(:actor_id, 'null') ||
|
||||||
|
|
@ -521,8 +505,7 @@ async def test_actor_actor_id_action_parameters_available(db):
|
||||||
', Action: ' || :action AS reason
|
', Action: ' || :action AS reason
|
||||||
WHERE :actor_id = 'test_user' AND :action = 'view-table'
|
WHERE :actor_id = 'test_user' AND :action = 'view-table'
|
||||||
AND json_extract(:actor, '$.role') = 'admin'
|
AND json_extract(:actor, '$.role') = 'admin'
|
||||||
""",
|
"""
|
||||||
{},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return provider
|
return provider
|
||||||
|
|
@ -567,7 +550,6 @@ async def test_multiple_plugins_with_own_parameters(db):
|
||||||
if action != "view-table":
|
if action != "view-table":
|
||||||
return PermissionSQL("plugin_one", "SELECT NULL WHERE 0", {})
|
return PermissionSQL("plugin_one", "SELECT NULL WHERE 0", {})
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"plugin_one",
|
|
||||||
"""
|
"""
|
||||||
SELECT database_name AS parent, table_name AS child,
|
SELECT database_name AS parent, table_name AS child,
|
||||||
1 AS allow, 'Plugin one used param: ' || :plugin1_param AS reason
|
1 AS allow, 'Plugin one used param: ' || :plugin1_param AS reason
|
||||||
|
|
@ -586,7 +568,6 @@ async def test_multiple_plugins_with_own_parameters(db):
|
||||||
if action != "view-table":
|
if action != "view-table":
|
||||||
return PermissionSQL("plugin_two", "SELECT NULL WHERE 0", {})
|
return PermissionSQL("plugin_two", "SELECT NULL WHERE 0", {})
|
||||||
return PermissionSQL(
|
return PermissionSQL(
|
||||||
"plugin_two",
|
|
||||||
"""
|
"""
|
||||||
SELECT database_name AS parent, table_name AS child,
|
SELECT database_name AS parent, table_name AS child,
|
||||||
1 AS allow, 'Plugin two used param: ' || :plugin2_param AS reason
|
1 AS allow, 'Plugin two used param: ' || :plugin2_param AS reason
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue