mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Compare commits
36 commits
main
...
resource-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
785f2ad0bd | ||
|
|
16b2729847 | ||
|
|
d1ea067fde | ||
|
|
3adddad6aa | ||
|
|
1134b22a27 | ||
|
|
11c039d35e | ||
|
|
3e6e8ee047 | ||
|
|
e4f5b5c30f | ||
|
|
bd5e969c8b | ||
|
|
e71c083700 | ||
|
|
e5316215aa | ||
|
|
092ada7b7d | ||
|
|
5919de0384 | ||
|
|
4880102b5d | ||
|
|
4b50cc7bc1 | ||
|
|
c4f0365130 | ||
|
|
19a37303c7 | ||
|
|
8de5b9431c | ||
|
|
275c06fbe4 | ||
|
|
d4dd08933e | ||
|
|
28a69d19a2 | ||
|
|
8bb07f80b1 | ||
|
|
4d93149c2b | ||
|
|
475f817c5a | ||
|
|
2039e238d9 | ||
|
|
e42b040055 | ||
|
|
f4245dce66 | ||
|
|
faef51ad05 | ||
|
|
5fc58c8775 | ||
|
|
1d37d30c2a | ||
|
|
5ed57607e5 | ||
|
|
cf887e0277 | ||
|
|
7dfd14bb07 | ||
|
|
3663b9df2d | ||
|
|
9e5c64c3de | ||
|
|
7db754c284 |
33 changed files with 3498 additions and 347 deletions
250
datasette/app.py
250
datasette/app.py
|
|
@ -52,6 +52,7 @@ from .views.special import (
|
|||
AllowedResourcesView,
|
||||
PermissionRulesView,
|
||||
PermissionCheckView,
|
||||
TablesView,
|
||||
)
|
||||
from .views.table import (
|
||||
TableInsertView,
|
||||
|
|
@ -114,7 +115,8 @@ from .tracer import AsgiTracer
|
|||
from .plugins import pm, DEFAULT_PLUGINS, get_plugins
|
||||
from .version import __version__
|
||||
|
||||
from .utils.permissions import build_rules_union, PluginSQL
|
||||
from .permissions import PermissionSQL
|
||||
from .utils.permissions import build_rules_union
|
||||
|
||||
app_root = Path(__file__).parent.parent
|
||||
|
||||
|
|
@ -308,6 +310,7 @@ class Datasette:
|
|||
self.immutables = set(immutables or [])
|
||||
self.databases = collections.OrderedDict()
|
||||
self.permissions = {} # .invoke_startup() will populate this
|
||||
self.actions = {} # .invoke_startup() will populate this
|
||||
try:
|
||||
self._refresh_schemas_lock = asyncio.Lock()
|
||||
except RuntimeError as rex:
|
||||
|
|
@ -391,10 +394,37 @@ class Datasette:
|
|||
config = config or {}
|
||||
config_settings = config.get("settings") or {}
|
||||
|
||||
# validate "settings" keys in datasette.json
|
||||
for key in config_settings:
|
||||
# Validate settings from config file
|
||||
for key, value in config_settings.items():
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
raise StartupError("Invalid setting '{}' in datasette.json".format(key))
|
||||
raise StartupError(f"Invalid setting '{key}' in config file")
|
||||
# Validate type matches expected type from DEFAULT_SETTINGS
|
||||
if value is not None: # Allow None/null values
|
||||
expected_type = type(DEFAULT_SETTINGS[key])
|
||||
actual_type = type(value)
|
||||
if actual_type != expected_type:
|
||||
raise StartupError(
|
||||
f"Setting '{key}' in config file has incorrect type. "
|
||||
f"Expected {expected_type.__name__}, got {actual_type.__name__}. "
|
||||
f"Value: {value!r}. "
|
||||
f"Hint: In YAML/JSON config files, remove quotes from boolean and integer values."
|
||||
)
|
||||
|
||||
# Validate settings from constructor parameter
|
||||
if settings:
|
||||
for key, value in settings.items():
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
raise StartupError(f"Invalid setting '{key}' in settings parameter")
|
||||
if value is not None:
|
||||
expected_type = type(DEFAULT_SETTINGS[key])
|
||||
actual_type = type(value)
|
||||
if actual_type != expected_type:
|
||||
raise StartupError(
|
||||
f"Setting '{key}' in settings parameter has incorrect type. "
|
||||
f"Expected {expected_type.__name__}, got {actual_type.__name__}. "
|
||||
f"Value: {value!r}"
|
||||
)
|
||||
|
||||
self.config = config
|
||||
# CLI settings should overwrite datasette.json settings
|
||||
self._settings = dict(DEFAULT_SETTINGS, **(config_settings), **(settings or {}))
|
||||
|
|
@ -457,6 +487,7 @@ class Datasette:
|
|||
self._register_renderers()
|
||||
self._permission_checks = collections.deque(maxlen=200)
|
||||
self._root_token = secrets.token_hex(32)
|
||||
self.root_enabled = False
|
||||
self.client = DatasetteClient(self)
|
||||
|
||||
async def apply_metadata_json(self):
|
||||
|
|
@ -589,6 +620,33 @@ class Datasette:
|
|||
if p.abbr:
|
||||
abbrs[p.abbr] = p
|
||||
self.permissions[p.name] = p
|
||||
|
||||
# Register actions, but watch out for duplicate name/abbr
|
||||
action_names = {}
|
||||
action_abbrs = {}
|
||||
for hook in pm.hook.register_actions(datasette=self):
|
||||
if hook:
|
||||
for action in hook:
|
||||
if (
|
||||
action.name in action_names
|
||||
and action != action_names[action.name]
|
||||
):
|
||||
raise StartupError(
|
||||
"Duplicate action name: {}".format(action.name)
|
||||
)
|
||||
if (
|
||||
action.abbr
|
||||
and action.abbr in action_abbrs
|
||||
and action != action_abbrs[action.abbr]
|
||||
):
|
||||
raise StartupError(
|
||||
"Duplicate action abbr: {}".format(action.abbr)
|
||||
)
|
||||
action_names[action.name] = action
|
||||
if action.abbr:
|
||||
action_abbrs[action.abbr] = action
|
||||
self.actions[action.name] = action
|
||||
|
||||
for hook in pm.hook.prepare_jinja2_environment(
|
||||
env=self._jinja_env, datasette=self
|
||||
):
|
||||
|
|
@ -1035,14 +1093,15 @@ class Datasette:
|
|||
)
|
||||
return result
|
||||
|
||||
async def allowed_resources_sql(
|
||||
async def _build_permission_rules_sql(
|
||||
self, actor: dict | None, action: str
|
||||
) -> tuple[str, dict]:
|
||||
"""Combine permission_resources_sql PluginSQL blocks into a UNION query.
|
||||
"""Combine permission_resources_sql PermissionSQL blocks into a UNION query.
|
||||
|
||||
Returns a (sql, params) tuple suitable for execution against SQLite.
|
||||
Internal helper for permission_allowed_2.
|
||||
"""
|
||||
plugin_blocks: List[PluginSQL] = []
|
||||
plugin_blocks: List[PermissionSQL] = []
|
||||
for block in pm.hook.permission_resources_sql(
|
||||
datasette=self,
|
||||
actor=actor,
|
||||
|
|
@ -1058,13 +1117,10 @@ class Datasette:
|
|||
for candidate in candidates:
|
||||
if candidate is None:
|
||||
continue
|
||||
if not isinstance(candidate, PluginSQL):
|
||||
continue
|
||||
plugin_blocks.append(candidate)
|
||||
|
||||
actor_id = actor.get("id") if actor else None
|
||||
sql, params = build_rules_union(
|
||||
actor=str(actor_id) if actor_id is not None else "",
|
||||
actor=actor,
|
||||
plugins=plugin_blocks,
|
||||
)
|
||||
return sql, params
|
||||
|
|
@ -1092,7 +1148,9 @@ class Datasette:
|
|||
elif resource is not None:
|
||||
raise TypeError("resource must be None, str, or (parent, child) tuple")
|
||||
|
||||
union_sql, union_params = await self.allowed_resources_sql(actor_dict, action)
|
||||
union_sql, union_params = await self._build_permission_rules_sql(
|
||||
actor_dict, action
|
||||
)
|
||||
|
||||
query = f"""
|
||||
WITH rules AS (
|
||||
|
|
@ -1242,6 +1300,170 @@ class Datasette:
|
|||
# It's visible to everyone
|
||||
return True, False
|
||||
|
||||
async def allowed_resources_sql(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
actor: dict | None = None,
|
||||
parent: str | None = None,
|
||||
include_is_private: bool = False,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Build SQL query to get all resources the actor can access for the given action.
|
||||
|
||||
Args:
|
||||
action: The action name (e.g., "view-table")
|
||||
actor: The actor dict (or None for unauthenticated)
|
||||
parent: Optional parent filter (e.g., database name) to limit results
|
||||
include_is_private: If True, include is_private column showing if anonymous cannot access
|
||||
|
||||
Returns a tuple of (query, params) that can be executed against the internal database.
|
||||
The query returns rows with (parent, child, reason) columns, plus is_private if requested.
|
||||
|
||||
Example:
|
||||
query, params = await datasette.allowed_resources_sql(
|
||||
action="view-table",
|
||||
actor=actor,
|
||||
parent="mydb",
|
||||
include_is_private=True
|
||||
)
|
||||
result = await datasette.get_internal_database().execute(query, params)
|
||||
"""
|
||||
from datasette.utils.actions_sql import build_allowed_resources_sql
|
||||
|
||||
action_obj = self.actions.get(action)
|
||||
if not action_obj:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
return await build_allowed_resources_sql(
|
||||
self, actor, action, parent=parent, include_is_private=include_is_private
|
||||
)
|
||||
|
||||
async def allowed_resources(
|
||||
self,
|
||||
action: str,
|
||||
actor: dict | None = None,
|
||||
*,
|
||||
parent: str | None = None,
|
||||
include_is_private: bool = False,
|
||||
) -> list["Resource"]:
|
||||
"""
|
||||
Return all resources the actor can access for the given action.
|
||||
|
||||
Uses SQL to filter resources based on cascading permission rules.
|
||||
Returns instances of the appropriate Resource subclass.
|
||||
|
||||
Args:
|
||||
action: The action name (e.g., "view-table")
|
||||
actor: The actor dict (or None for unauthenticated)
|
||||
parent: Optional parent filter (e.g., database name) to limit results
|
||||
include_is_private: If True, adds a .private attribute to each Resource
|
||||
|
||||
Example:
|
||||
# Get all tables
|
||||
tables = await datasette.allowed_resources("view-table", actor)
|
||||
for table in tables:
|
||||
print(f"{table.parent}/{table.child}")
|
||||
|
||||
# Get tables for specific database with private flag
|
||||
tables = await datasette.allowed_resources(
|
||||
"view-table", actor, parent="mydb", include_is_private=True
|
||||
)
|
||||
for table in tables:
|
||||
if table.private:
|
||||
print(f"{table.child} is private")
|
||||
"""
|
||||
from datasette.permissions import Resource
|
||||
|
||||
action_obj = self.actions.get(action)
|
||||
if not action_obj:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
query, params = await self.allowed_resources_sql(
|
||||
action=action,
|
||||
actor=actor,
|
||||
parent=parent,
|
||||
include_is_private=include_is_private,
|
||||
)
|
||||
result = await self.get_internal_database().execute(query, params)
|
||||
|
||||
# Instantiate the appropriate Resource subclass for each row
|
||||
resource_class = action_obj.resource_class
|
||||
resources = []
|
||||
for row in result.rows:
|
||||
# row[0]=parent, row[1]=child, row[2]=reason (ignored), row[3]=is_private (if requested)
|
||||
# Create instance directly with parent/child from base class
|
||||
resource = object.__new__(resource_class)
|
||||
Resource.__init__(resource, parent=row[0], child=row[1])
|
||||
if include_is_private:
|
||||
resource.private = bool(row[3])
|
||||
resources.append(resource)
|
||||
|
||||
return resources
|
||||
|
||||
async def allowed_resources_with_reasons(
|
||||
self,
|
||||
action: str,
|
||||
actor: dict | None = None,
|
||||
) -> list["AllowedResource"]:
|
||||
"""
|
||||
Return allowed resources with permission reasons for debugging.
|
||||
|
||||
Uses SQL to filter resources and includes the reason each was allowed.
|
||||
Returns list of AllowedResource named tuples with (resource, reason).
|
||||
|
||||
Example:
|
||||
debug_info = await datasette.allowed_resources_with_reasons("view-table", actor)
|
||||
for allowed in debug_info:
|
||||
print(f"{allowed.resource}: {allowed.reason}")
|
||||
"""
|
||||
from datasette.permissions import AllowedResource, Resource
|
||||
|
||||
action_obj = self.actions.get(action)
|
||||
if not action_obj:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
query, params = await self.allowed_resources_sql(action=action, actor=actor)
|
||||
result = await self.get_internal_database().execute(query, params)
|
||||
|
||||
resource_class = action_obj.resource_class
|
||||
resources = []
|
||||
for row in result.rows:
|
||||
# Create instance directly with parent/child from base class
|
||||
resource = object.__new__(resource_class)
|
||||
Resource.__init__(resource, parent=row[0], child=row[1])
|
||||
reason = row[2]
|
||||
resources.append(AllowedResource(resource=resource, reason=reason))
|
||||
|
||||
return resources
|
||||
|
||||
async def allowed(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
resource: "Resource",
|
||||
actor: dict | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if actor can perform action on specific resource.
|
||||
|
||||
Uses SQL to check permission for a single resource without fetching all resources.
|
||||
This is efficient - it does NOT call allowed_resources() and check membership.
|
||||
|
||||
Example:
|
||||
from datasette.resources import TableResource
|
||||
can_view = await datasette.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource(database="analytics", table="users"),
|
||||
actor=actor
|
||||
)
|
||||
"""
|
||||
from datasette.utils.actions_sql import check_permission_for_resource
|
||||
|
||||
return await check_permission_for_resource(
|
||||
self, actor, action, resource.parent, resource.child
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
db_name,
|
||||
|
|
@ -1726,6 +1948,10 @@ class Datasette:
|
|||
ApiExplorerView.as_view(self),
|
||||
r"/-/api$",
|
||||
)
|
||||
add_route(
|
||||
TablesView.as_view(self),
|
||||
r"/-/tables(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
LogoutView.as_view(self),
|
||||
r"/-/logout$",
|
||||
|
|
|
|||
|
|
@ -648,6 +648,7 @@ def serve(
|
|||
# Start the server
|
||||
url = None
|
||||
if root:
|
||||
ds.root_enabled = True
|
||||
url = "http://{}:{}{}?token={}".format(
|
||||
host, port, ds.urls.path("-/auth-token"), ds._root_token
|
||||
)
|
||||
|
|
|
|||
131
datasette/default_actions.py
Normal file
131
datasette/default_actions.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.permissions import Action
|
||||
from datasette.resources import (
|
||||
InstanceResource,
|
||||
DatabaseResource,
|
||||
TableResource,
|
||||
QueryResource,
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_actions():
|
||||
"""Register the core Datasette actions."""
|
||||
return (
|
||||
# View actions
|
||||
Action(
|
||||
name="view-instance",
|
||||
abbr="vi",
|
||||
description="View Datasette instance",
|
||||
takes_parent=False,
|
||||
takes_child=False,
|
||||
resource_class=InstanceResource,
|
||||
),
|
||||
Action(
|
||||
name="view-database",
|
||||
abbr="vd",
|
||||
description="View database",
|
||||
takes_parent=True,
|
||||
takes_child=False,
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="view-database-download",
|
||||
abbr="vdd",
|
||||
description="Download database file",
|
||||
takes_parent=True,
|
||||
takes_child=False,
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="view-table",
|
||||
abbr="vt",
|
||||
description="View table",
|
||||
takes_parent=True,
|
||||
takes_child=True,
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="view-query",
|
||||
abbr="vq",
|
||||
description="View named query results",
|
||||
takes_parent=True,
|
||||
takes_child=True,
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
Action(
|
||||
name="execute-sql",
|
||||
abbr="es",
|
||||
description="Execute read-only SQL queries",
|
||||
takes_parent=True,
|
||||
takes_child=False,
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
# Debug actions
|
||||
Action(
|
||||
name="permissions-debug",
|
||||
abbr="pd",
|
||||
description="Access permission debug tool",
|
||||
takes_parent=False,
|
||||
takes_child=False,
|
||||
resource_class=InstanceResource,
|
||||
),
|
||||
Action(
|
||||
name="debug-menu",
|
||||
abbr="dm",
|
||||
description="View debug menu items",
|
||||
takes_parent=False,
|
||||
takes_child=False,
|
||||
resource_class=InstanceResource,
|
||||
),
|
||||
# Write actions on tables
|
||||
Action(
|
||||
name="insert-row",
|
||||
abbr="ir",
|
||||
description="Insert rows",
|
||||
takes_parent=True,
|
||||
takes_child=True,
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="delete-row",
|
||||
abbr="dr",
|
||||
description="Delete rows",
|
||||
takes_parent=True,
|
||||
takes_child=True,
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="update-row",
|
||||
abbr="ur",
|
||||
description="Update rows",
|
||||
takes_parent=True,
|
||||
takes_child=True,
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="alter-table",
|
||||
abbr="at",
|
||||
description="Alter tables",
|
||||
takes_parent=True,
|
||||
takes_child=True,
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="drop-table",
|
||||
abbr="dt",
|
||||
description="Drop tables",
|
||||
takes_parent=True,
|
||||
takes_child=True,
|
||||
resource_class=TableResource,
|
||||
),
|
||||
# Schema actions on databases
|
||||
Action(
|
||||
name="create-table",
|
||||
abbr="ct",
|
||||
description="Create tables",
|
||||
takes_parent=True,
|
||||
takes_child=False,
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from datasette import hookimpl, Permission
|
||||
from datasette.utils.permissions import PluginSQL
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.utils import actor_matches_allow
|
||||
import itsdangerous
|
||||
import time
|
||||
|
|
@ -128,57 +128,119 @@ def register_permissions():
|
|||
|
||||
|
||||
@hookimpl(tryfirst=True, specname="permission_allowed")
|
||||
def permission_allowed_default(datasette, actor, action, resource):
|
||||
async def inner():
|
||||
# id=root gets some special permissions:
|
||||
if action in (
|
||||
"permissions-debug",
|
||||
"debug-menu",
|
||||
"insert-row",
|
||||
"create-table",
|
||||
"alter-table",
|
||||
"drop-table",
|
||||
"delete-row",
|
||||
"update-row",
|
||||
):
|
||||
if actor and actor.get("id") == "root":
|
||||
return True
|
||||
async def permission_allowed_sql_bridge(datasette, actor, action, resource):
|
||||
"""
|
||||
Bridge config-based permission rules to the old permission_allowed API.
|
||||
|
||||
# Resolve view permissions in allow blocks in configuration
|
||||
if action in (
|
||||
"view-instance",
|
||||
"view-database",
|
||||
"view-table",
|
||||
"view-query",
|
||||
"execute-sql",
|
||||
):
|
||||
result = await _resolve_config_view_permissions(
|
||||
datasette, actor, action, resource
|
||||
This allows views using the old string/tuple resource API to benefit from
|
||||
config blocks defined in datasette.yaml without using the new resource-based system.
|
||||
|
||||
Note: This does NOT apply default allow rules - those should come from the
|
||||
Permission object's default value to maintain backward compatibility.
|
||||
"""
|
||||
# Only check config-based rules - don't apply defaults
|
||||
config_rules = await _config_permission_rules(datasette, actor, action)
|
||||
if not config_rules:
|
||||
return None
|
||||
|
||||
# Evaluate config rules for this specific resource
|
||||
for rule in config_rules:
|
||||
if rule.params: # Has config-based rules
|
||||
from datasette.utils.permissions import resolve_permissions_with_candidates
|
||||
|
||||
# Build candidate based on resource
|
||||
if resource is None:
|
||||
candidates = [(None, None)]
|
||||
elif isinstance(resource, str):
|
||||
candidates = [(resource, None)]
|
||||
elif isinstance(resource, tuple):
|
||||
candidates = [(resource[0], resource[1])]
|
||||
else:
|
||||
return None
|
||||
|
||||
db = datasette.get_internal_database()
|
||||
results = await resolve_permissions_with_candidates(
|
||||
db, actor, [rule], candidates, action, implicit_deny=False
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
if results:
|
||||
# Use the first result's allow value
|
||||
for result in results:
|
||||
if result.get("allow") is not None:
|
||||
return bool(result["allow"])
|
||||
return None
|
||||
|
||||
# Resolve custom permissions: blocks in configuration
|
||||
result = await _resolve_config_permissions_blocks(
|
||||
datasette, actor, action, resource
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# --setting default_allow_sql
|
||||
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
|
||||
@hookimpl(tryfirst=True, specname="permission_allowed")
|
||||
def permission_allowed_default_allow_sql(datasette, actor, action, resource):
|
||||
"""
|
||||
Enforce the default_allow_sql setting for execute-sql permission.
|
||||
|
||||
When default_allow_sql is set to False, deny all execute-sql permissions.
|
||||
This runs before other permission checks to ensure the setting is respected.
|
||||
"""
|
||||
if action == "execute-sql":
|
||||
if not datasette.setting("default_allow_sql"):
|
||||
return False
|
||||
return None
|
||||
|
||||
return inner
|
||||
|
||||
@hookimpl(tryfirst=True, specname="permission_allowed")
|
||||
def permission_allowed_root(datasette, actor, action, resource):
|
||||
"""
|
||||
Grant all permissions to root user when Datasette started with --root flag.
|
||||
|
||||
The --root flag is a localhost development tool. When used, it sets
|
||||
datasette.root_enabled = True and creates an actor with id="root".
|
||||
This hook grants that actor all permissions.
|
||||
|
||||
Other plugins can use the same pattern: check datasette.root_enabled
|
||||
to decide whether to honor root users.
|
||||
"""
|
||||
if datasette.root_enabled and actor and actor.get("id") == "root":
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl
|
||||
async def permission_resources_sql(datasette, actor, action):
|
||||
rules: list[PluginSQL] = []
|
||||
rules: list[PermissionSQL] = []
|
||||
|
||||
# Root user with root_enabled gets all permissions at global level
|
||||
# Config rules at more specific levels (database/table) can still override
|
||||
if datasette.root_enabled and actor and actor.get("id") == "root":
|
||||
# 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={},
|
||||
)
|
||||
)
|
||||
|
||||
config_rules = await _config_permission_rules(datasette, actor, action)
|
||||
rules.extend(config_rules)
|
||||
|
||||
# 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={},
|
||||
)
|
||||
)
|
||||
# Early return - don't add default allow rule
|
||||
if not rules:
|
||||
return None
|
||||
if len(rules) == 1:
|
||||
return rules[0]
|
||||
return rules
|
||||
|
||||
default_allow_actions = {
|
||||
"view-instance",
|
||||
"view-database",
|
||||
|
|
@ -191,7 +253,7 @@ async def permission_resources_sql(datasette, actor, action):
|
|||
"SELECT NULL AS parent, NULL AS child, 1 AS allow, " f"'{reason}' AS reason"
|
||||
)
|
||||
rules.append(
|
||||
PluginSQL(
|
||||
PermissionSQL(
|
||||
source="default_permissions",
|
||||
sql=sql,
|
||||
params={},
|
||||
|
|
@ -205,7 +267,7 @@ async def permission_resources_sql(datasette, actor, action):
|
|||
return rules
|
||||
|
||||
|
||||
async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]:
|
||||
async def _config_permission_rules(datasette, actor, action) -> list[PermissionSQL]:
|
||||
config = datasette.config or {}
|
||||
|
||||
if actor is None:
|
||||
|
|
@ -235,6 +297,21 @@ async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]:
|
|||
)
|
||||
)
|
||||
|
||||
def add_row_allow_block(parent, child, allow_block, scope):
|
||||
"""For 'allow' blocks, always add a row if the block exists - deny if no match"""
|
||||
if allow_block is None:
|
||||
return
|
||||
result = evaluate(allow_block)
|
||||
# If result is None (no match) or False, treat as deny
|
||||
rows.append(
|
||||
(
|
||||
parent,
|
||||
child,
|
||||
bool(result), # None becomes False, False stays False, True stays True
|
||||
f"config {'allow' if result else 'deny'} {scope}",
|
||||
)
|
||||
)
|
||||
|
||||
root_perm = (config.get("permissions") or {}).get(action)
|
||||
add_row(None, None, evaluate(root_perm), f"permissions for {action}")
|
||||
|
||||
|
|
@ -255,59 +332,79 @@ async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]:
|
|||
|
||||
if action == "view-table":
|
||||
table_allow = (table_config or {}).get("allow")
|
||||
add_row(
|
||||
add_row_allow_block(
|
||||
db_name,
|
||||
table_name,
|
||||
evaluate(table_allow),
|
||||
table_allow,
|
||||
f"allow for {action} on {db_name}/{table_name}",
|
||||
)
|
||||
|
||||
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||
query_perm = (query_config.get("permissions") or {}).get(action)
|
||||
add_row(
|
||||
db_name,
|
||||
query_name,
|
||||
evaluate(query_perm),
|
||||
f"permissions for {action} on {db_name}/{query_name}",
|
||||
)
|
||||
if action == "view-query":
|
||||
query_allow = (query_config or {}).get("allow")
|
||||
# query_config can be a string (just SQL) or a dict (with SQL and options)
|
||||
if isinstance(query_config, dict):
|
||||
query_perm = (query_config.get("permissions") or {}).get(action)
|
||||
add_row(
|
||||
db_name,
|
||||
query_name,
|
||||
evaluate(query_allow),
|
||||
f"allow for {action} on {db_name}/{query_name}",
|
||||
evaluate(query_perm),
|
||||
f"permissions for {action} on {db_name}/{query_name}",
|
||||
)
|
||||
if action == "view-query":
|
||||
query_allow = query_config.get("allow")
|
||||
add_row_allow_block(
|
||||
db_name,
|
||||
query_name,
|
||||
query_allow,
|
||||
f"allow for {action} on {db_name}/{query_name}",
|
||||
)
|
||||
|
||||
if action == "view-database":
|
||||
db_allow = db_config.get("allow")
|
||||
add_row(
|
||||
db_name, None, evaluate(db_allow), f"allow for {action} on {db_name}"
|
||||
add_row_allow_block(
|
||||
db_name, None, db_allow, f"allow for {action} on {db_name}"
|
||||
)
|
||||
|
||||
if action == "execute-sql":
|
||||
db_allow_sql = db_config.get("allow_sql")
|
||||
add_row(db_name, None, evaluate(db_allow_sql), f"allow_sql for {db_name}")
|
||||
add_row_allow_block(db_name, None, db_allow_sql, f"allow_sql for {db_name}")
|
||||
|
||||
if action == "view-table":
|
||||
# Database-level allow block affects all tables in that database
|
||||
db_allow = db_config.get("allow")
|
||||
add_row_allow_block(
|
||||
db_name, None, db_allow, f"allow for {action} on {db_name}"
|
||||
)
|
||||
|
||||
if action == "view-query":
|
||||
# Database-level allow block affects all queries in that database
|
||||
db_allow = db_config.get("allow")
|
||||
add_row_allow_block(
|
||||
db_name, None, db_allow, f"allow for {action} on {db_name}"
|
||||
)
|
||||
|
||||
# Root-level allow block applies to all view-* actions
|
||||
if action == "view-instance":
|
||||
allow_block = config.get("allow")
|
||||
add_row(None, None, evaluate(allow_block), "allow for view-instance")
|
||||
add_row_allow_block(None, None, allow_block, "allow for view-instance")
|
||||
|
||||
if action == "view-database":
|
||||
# Root-level allow block also applies to view-database
|
||||
allow_block = config.get("allow")
|
||||
add_row_allow_block(None, None, allow_block, "allow for view-database")
|
||||
|
||||
if action == "view-table":
|
||||
# Tables handled in loop
|
||||
pass
|
||||
# Root-level allow block also applies to view-table
|
||||
allow_block = config.get("allow")
|
||||
add_row_allow_block(None, None, allow_block, "allow for view-table")
|
||||
|
||||
if action == "view-query":
|
||||
# Queries handled in loop
|
||||
pass
|
||||
# Root-level allow block also applies to view-query
|
||||
allow_block = config.get("allow")
|
||||
add_row_allow_block(None, None, allow_block, "allow for view-query")
|
||||
|
||||
if action == "execute-sql":
|
||||
allow_sql = config.get("allow_sql")
|
||||
add_row(None, None, evaluate(allow_sql), "allow_sql")
|
||||
|
||||
if action == "view-database":
|
||||
# already handled per-database
|
||||
pass
|
||||
add_row_allow_block(None, None, allow_sql, "allow_sql")
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
|
@ -325,109 +422,7 @@ async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]:
|
|||
params[f"{key}_reason"] = reason
|
||||
|
||||
sql = "\nUNION ALL\n".join(parts)
|
||||
print(sql, params)
|
||||
return [PluginSQL(source="config_permissions", sql=sql, params=params)]
|
||||
|
||||
|
||||
async def _resolve_config_permissions_blocks(datasette, actor, action, resource):
|
||||
# Check custom permissions: blocks
|
||||
config = datasette.config or {}
|
||||
root_block = (config.get("permissions", None) or {}).get(action)
|
||||
if root_block:
|
||||
root_result = actor_matches_allow(actor, root_block)
|
||||
if root_result is not None:
|
||||
return root_result
|
||||
# Now try database-specific blocks
|
||||
if not resource:
|
||||
return None
|
||||
if isinstance(resource, str):
|
||||
database = resource
|
||||
else:
|
||||
database = resource[0]
|
||||
database_block = (
|
||||
(config.get("databases", {}).get(database, {}).get("permissions", None)) or {}
|
||||
).get(action)
|
||||
if database_block:
|
||||
database_result = actor_matches_allow(actor, database_block)
|
||||
if database_result is not None:
|
||||
return database_result
|
||||
# Finally try table/query specific blocks
|
||||
if not isinstance(resource, tuple):
|
||||
return None
|
||||
database, table_or_query = resource
|
||||
table_block = (
|
||||
(
|
||||
config.get("databases", {})
|
||||
.get(database, {})
|
||||
.get("tables", {})
|
||||
.get(table_or_query, {})
|
||||
.get("permissions", None)
|
||||
)
|
||||
or {}
|
||||
).get(action)
|
||||
if table_block:
|
||||
table_result = actor_matches_allow(actor, table_block)
|
||||
if table_result is not None:
|
||||
return table_result
|
||||
# Finally the canned queries
|
||||
query_block = (
|
||||
(
|
||||
config.get("databases", {})
|
||||
.get(database, {})
|
||||
.get("queries", {})
|
||||
.get(table_or_query, {})
|
||||
.get("permissions", None)
|
||||
)
|
||||
or {}
|
||||
).get(action)
|
||||
if query_block:
|
||||
query_result = actor_matches_allow(actor, query_block)
|
||||
if query_result is not None:
|
||||
return query_result
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_config_view_permissions(datasette, actor, action, resource):
|
||||
config = datasette.config or {}
|
||||
if action == "view-instance":
|
||||
allow = config.get("allow")
|
||||
if allow is not None:
|
||||
return actor_matches_allow(actor, allow)
|
||||
elif action == "view-database":
|
||||
database_allow = ((config.get("databases") or {}).get(resource) or {}).get(
|
||||
"allow"
|
||||
)
|
||||
if database_allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, database_allow)
|
||||
elif action == "view-table":
|
||||
database, table = resource
|
||||
tables = ((config.get("databases") or {}).get(database) or {}).get(
|
||||
"tables"
|
||||
) or {}
|
||||
table_allow = (tables.get(table) or {}).get("allow")
|
||||
if table_allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, table_allow)
|
||||
elif action == "view-query":
|
||||
# Check if this query has a "allow" block in config
|
||||
database, query_name = resource
|
||||
query = await datasette.get_canned_query(database, query_name, actor)
|
||||
assert query is not None
|
||||
allow = query.get("allow")
|
||||
if allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, allow)
|
||||
elif action == "execute-sql":
|
||||
# Use allow_sql block from database block, or from top-level
|
||||
database_allow_sql = ((config.get("databases") or {}).get(resource) or {}).get(
|
||||
"allow_sql"
|
||||
)
|
||||
if database_allow_sql is None:
|
||||
database_allow_sql = config.get("allow_sql")
|
||||
if database_allow_sql is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, database_allow_sql)
|
||||
return [PermissionSQL(source="config_permissions", sql=sql, params=params)]
|
||||
|
||||
|
||||
def restrictions_allow_action(
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ def register_permissions(datasette):
|
|||
"""Register permissions: returns a list of datasette.permission.Permission named tuples"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_actions(datasette):
|
||||
"""Register actions: returns a list of datasette.permission.Action objects"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_routes(datasette):
|
||||
"""Register URL routes: return a list of (regex, view_function) pairs"""
|
||||
|
|
@ -119,8 +124,8 @@ def permission_allowed(datasette, actor, action, resource):
|
|||
def permission_resources_sql(datasette, actor, action):
|
||||
"""Return SQL query fragments for permission checks on resources.
|
||||
|
||||
Returns None, a PluginSQL object, or a list of PluginSQL objects.
|
||||
Each PluginSQL contains SQL that should return rows with columns:
|
||||
Returns None, a PermissionSQL object, or a list of PermissionSQL objects.
|
||||
Each PermissionSQL contains SQL that should return rows with columns:
|
||||
parent (str|None), child (str|None), allow (int), reason (str).
|
||||
|
||||
Used to efficiently check permissions across multiple resources at once.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,99 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, Optional, NamedTuple
|
||||
|
||||
|
||||
class Resource(ABC):
|
||||
"""
|
||||
Base class for all resource types.
|
||||
|
||||
Each subclass represents a type of resource (e.g., TableResource, DatabaseResource).
|
||||
The class itself carries metadata about the resource type.
|
||||
Instances represent specific resources.
|
||||
"""
|
||||
|
||||
# Class-level metadata (subclasses must define these)
|
||||
name: str = None # e.g., "table", "database", "model"
|
||||
parent_name: Optional[str] = None # e.g., "database" for tables
|
||||
|
||||
def __init__(self, parent: Optional[str] = None, child: Optional[str] = None):
|
||||
"""
|
||||
Create a resource instance.
|
||||
|
||||
Args:
|
||||
parent: The parent identifier (meaning depends on resource type)
|
||||
child: The child identifier (meaning depends on resource type)
|
||||
"""
|
||||
self.parent = parent
|
||||
self.child = child
|
||||
self._private = None # Sentinel to track if private was set
|
||||
|
||||
@property
|
||||
def private(self) -> bool:
|
||||
"""
|
||||
Whether this resource is private (accessible to actor but not anonymous).
|
||||
|
||||
This property is only available on Resource objects returned from
|
||||
allowed_resources() when include_is_private=True is used.
|
||||
|
||||
Raises:
|
||||
AttributeError: If accessed without calling include_is_private=True
|
||||
"""
|
||||
if self._private is None:
|
||||
raise AttributeError(
|
||||
"The 'private' attribute is only available when using "
|
||||
"allowed_resources(..., include_is_private=True)"
|
||||
)
|
||||
return self._private
|
||||
|
||||
@private.setter
|
||||
def private(self, value: bool):
|
||||
self._private = value
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def resources_sql(cls) -> str:
|
||||
"""
|
||||
Return SQL query that returns all resources of this type.
|
||||
|
||||
Must return two columns: parent, child
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class AllowedResource(NamedTuple):
|
||||
"""A resource with the reason it was allowed (for debugging)."""
|
||||
|
||||
resource: Resource
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
name: str
|
||||
abbr: str | None
|
||||
description: str | None
|
||||
takes_parent: bool
|
||||
takes_child: bool
|
||||
resource_class: type[Resource]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionSQL:
|
||||
"""
|
||||
A plugin contributes SQL that yields:
|
||||
parent TEXT NULL,
|
||||
child TEXT NULL,
|
||||
allow INTEGER, -- 1 allow, 0 deny
|
||||
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)
|
||||
|
||||
|
||||
# This is obsolete, replaced by Action and ResourceType
|
||||
@dataclass
|
||||
class Permission:
|
||||
name: str
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ DEFAULT_PLUGINS = (
|
|||
"datasette.sql_functions",
|
||||
"datasette.actor_auth_cookie",
|
||||
"datasette.default_permissions",
|
||||
"datasette.default_actions",
|
||||
"datasette.default_magic_parameters",
|
||||
"datasette.blob_renderer",
|
||||
"datasette.default_menu_links",
|
||||
|
|
|
|||
69
datasette/resources.py
Normal file
69
datasette/resources.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Core resource types for Datasette's permission system."""
|
||||
|
||||
from datasette.permissions import Resource
|
||||
|
||||
|
||||
class InstanceResource(Resource):
|
||||
"""The Datasette instance itself."""
|
||||
|
||||
name = "instance"
|
||||
parent_name = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(parent=None, child=None)
|
||||
|
||||
@classmethod
|
||||
def resources_sql(cls) -> str:
|
||||
return "SELECT NULL AS parent, NULL AS child"
|
||||
|
||||
|
||||
class DatabaseResource(Resource):
|
||||
"""A database in Datasette."""
|
||||
|
||||
name = "database"
|
||||
parent_name = "instance"
|
||||
|
||||
def __init__(self, database: str):
|
||||
super().__init__(parent=database, child=None)
|
||||
|
||||
@classmethod
|
||||
def resources_sql(cls) -> str:
|
||||
return """
|
||||
SELECT database_name AS parent, NULL AS child
|
||||
FROM catalog_databases
|
||||
"""
|
||||
|
||||
|
||||
class TableResource(Resource):
|
||||
"""A table in a database."""
|
||||
|
||||
name = "table"
|
||||
parent_name = "database"
|
||||
|
||||
def __init__(self, database: str, table: str):
|
||||
super().__init__(parent=database, child=table)
|
||||
|
||||
@classmethod
|
||||
def resources_sql(cls) -> str:
|
||||
return """
|
||||
SELECT database_name AS parent, table_name AS child
|
||||
FROM catalog_tables
|
||||
UNION ALL
|
||||
SELECT database_name AS parent, view_name AS child
|
||||
FROM catalog_views
|
||||
"""
|
||||
|
||||
|
||||
class QueryResource(Resource):
|
||||
"""A canned query in a database."""
|
||||
|
||||
name = "query"
|
||||
parent_name = "database"
|
||||
|
||||
def __init__(self, database: str, query: str):
|
||||
super().__init__(parent=database, child=query)
|
||||
|
||||
@classmethod
|
||||
def resources_sql(cls) -> str:
|
||||
# TODO: Need catalog for queries
|
||||
return "SELECT NULL AS parent, NULL AS child WHERE 0"
|
||||
417
datasette/static/navigation-search.js
Normal file
417
datasette/static/navigation-search.js
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
class NavigationSearch extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.selectedIndex = -1;
|
||||
this.matches = [];
|
||||
this.debounceTimer = null;
|
||||
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
dialog {
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0;
|
||||
max-width: 90vw;
|
||||
width: 600px;
|
||||
max-height: 80vh;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
overflow-y: auto;
|
||||
height: calc(80vh - 180px);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 0.875rem 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.result-item.selected {
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint-text kbd {
|
||||
background: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 640px) {
|
||||
dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<dialog>
|
||||
<div class="search-container">
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
aria-label="Search navigation"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
<div class="results-container" role="listbox"></div>
|
||||
<div class="hint-text">
|
||||
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
||||
<span><kbd>Enter</kbd> Select</span>
|
||||
<span><kbd>Esc</kbd> Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
const resultsContainer = this.shadowRoot.querySelector(
|
||||
".results-container"
|
||||
);
|
||||
|
||||
// Global keyboard listener for "/"
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "/" && !this.isInputFocused() && !dialog.open) {
|
||||
e.preventDefault();
|
||||
this.openMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Input event
|
||||
input.addEventListener("input", (e) => {
|
||||
this.handleSearch(e.target.value);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
this.moveSelection(1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
this.moveSelection(-1);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.selectCurrentItem();
|
||||
} else if (e.key === "Escape") {
|
||||
this.closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Click on result item
|
||||
resultsContainer.addEventListener("click", (e) => {
|
||||
const item = e.target.closest(".result-item");
|
||||
if (item) {
|
||||
const index = parseInt(item.dataset.index);
|
||||
this.selectItem(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
this.closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load
|
||||
this.loadInitialData();
|
||||
}
|
||||
|
||||
isInputFocused() {
|
||||
const activeElement = document.activeElement;
|
||||
return (
|
||||
activeElement &&
|
||||
(activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
activeElement.isContentEditable)
|
||||
);
|
||||
}
|
||||
|
||||
loadInitialData() {
|
||||
const itemsAttr = this.getAttribute("items");
|
||||
if (itemsAttr) {
|
||||
try {
|
||||
this.allItems = JSON.parse(itemsAttr);
|
||||
this.matches = this.allItems;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse items attribute:", e);
|
||||
this.allItems = [];
|
||||
this.matches = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSearch(query) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
const url = this.getAttribute("url");
|
||||
|
||||
if (url) {
|
||||
// Fetch from API
|
||||
this.fetchResults(url, query);
|
||||
} else {
|
||||
// Filter local items
|
||||
this.filterLocalItems(query);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async fetchResults(url, query) {
|
||||
try {
|
||||
const searchUrl = `${url}?q=${encodeURIComponent(query)}`;
|
||||
const response = await fetch(searchUrl);
|
||||
const data = await response.json();
|
||||
this.matches = data.matches || [];
|
||||
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
||||
this.renderResults();
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch search results:", e);
|
||||
this.matches = [];
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
filterLocalItems(query) {
|
||||
if (!query.trim()) {
|
||||
this.matches = [];
|
||||
} else {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
this.matches = (this.allItems || []).filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(lowerQuery) ||
|
||||
item.url.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
||||
this.renderResults();
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.shadowRoot.querySelector(".results-container");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
|
||||
if (this.matches.length === 0) {
|
||||
const message = input.value.trim()
|
||||
? "No results found"
|
||||
: "Start typing to search...";
|
||||
container.innerHTML = `<div class="no-results">${message}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.matches
|
||||
.map(
|
||||
(match, index) => `
|
||||
<div
|
||||
class="result-item ${
|
||||
index === this.selectedIndex ? "selected" : ""
|
||||
}"
|
||||
data-index="${index}"
|
||||
role="option"
|
||||
aria-selected="${index === this.selectedIndex}"
|
||||
>
|
||||
<div>
|
||||
<div class="result-name">${this.escapeHtml(
|
||||
match.name
|
||||
)}</div>
|
||||
<div class="result-url">${this.escapeHtml(match.url)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
// Scroll selected item into view
|
||||
if (this.selectedIndex >= 0) {
|
||||
const selectedItem = container.children[this.selectedIndex];
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveSelection(direction) {
|
||||
const newIndex = this.selectedIndex + direction;
|
||||
if (newIndex >= 0 && newIndex < this.matches.length) {
|
||||
this.selectedIndex = newIndex;
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrentItem() {
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
|
||||
this.selectItem(this.selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
selectItem(index) {
|
||||
const match = this.matches[index];
|
||||
if (match) {
|
||||
// Dispatch custom event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("select", {
|
||||
detail: match,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Navigate to URL
|
||||
window.location.href = match.url;
|
||||
|
||||
this.closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
openMenu() {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
|
||||
dialog.showModal();
|
||||
input.value = "";
|
||||
input.focus();
|
||||
|
||||
// Reset state - start with no items shown
|
||||
this.matches = [];
|
||||
this.selectedIndex = -1;
|
||||
this.renderResults();
|
||||
}
|
||||
|
||||
closeMenu() {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define("navigation-search", NavigationSearch);
|
||||
|
|
@ -72,5 +72,7 @@
|
|||
{% endfor %}
|
||||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||
<navigation-search url="/-/tables"></navigation-search>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
441
datasette/utils/actions_sql.py
Normal file
441
datasette/utils/actions_sql.py
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
"""
|
||||
SQL query builder for hierarchical permission checking.
|
||||
|
||||
This module implements a cascading permission system based on the pattern
|
||||
from https://github.com/simonw/research/tree/main/sqlite-permissions-poc
|
||||
|
||||
It builds SQL queries that:
|
||||
|
||||
1. Start with all resources of a given type (from resource_type.resources_sql())
|
||||
2. Gather permission rules from plugins (via permission_resources_sql hook)
|
||||
3. Apply cascading logic: child → parent → global
|
||||
4. Apply DENY-beats-ALLOW at each level
|
||||
|
||||
The core pattern is:
|
||||
- Resources are identified by (parent, child) tuples
|
||||
- Rules are evaluated at three levels:
|
||||
- child: exact match on (parent, child)
|
||||
- parent: match on (parent, NULL)
|
||||
- global: match on (NULL, NULL)
|
||||
- At the same level, DENY (allow=0) beats ALLOW (allow=1)
|
||||
- Across levels, child beats parent beats global
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from datasette.plugins import pm
|
||||
from datasette.utils import await_me_maybe
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
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,
|
||||
action: str,
|
||||
*,
|
||||
parent: str | None = None,
|
||||
include_is_private: bool = False,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Build a SQL query that returns all resources the actor can access for this action.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
actor: The actor dict (or None for unauthenticated)
|
||||
action: The action name (e.g., "view-table", "view-database")
|
||||
parent: Optional parent filter to limit results (e.g., database name)
|
||||
include_is_private: If True, add is_private column showing if anonymous cannot access
|
||||
|
||||
Returns:
|
||||
A tuple of (sql_query, params_dict)
|
||||
|
||||
The returned SQL query will have three columns (or four with include_is_private):
|
||||
- parent: The parent resource identifier (or NULL)
|
||||
- child: The child resource identifier (or NULL)
|
||||
- reason: The reason from the rule that granted access
|
||||
- is_private: (if include_is_private) 1 if anonymous cannot access, 0 otherwise
|
||||
|
||||
Example:
|
||||
For action="view-table", this might return:
|
||||
SELECT parent, child, reason FROM ... WHERE is_allowed = 1
|
||||
|
||||
Results would be like:
|
||||
('analytics', 'users', 'role-based: analysts can access analytics DB')
|
||||
('analytics', 'events', 'role-based: analysts can access analytics DB')
|
||||
('production', 'orders', 'business-exception: allow production.orders for carol')
|
||||
"""
|
||||
# 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()
|
||||
|
||||
# Get all permission rule fragments from plugins via the hook
|
||||
rule_results = pm.hook.permission_resources_sql(
|
||||
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)
|
||||
|
||||
# If no rules, return empty result (deny all)
|
||||
if not rule_sqls:
|
||||
empty_cols = "NULL AS parent, NULL AS child, NULL AS reason"
|
||||
if include_is_private:
|
||||
empty_cols += ", NULL AS is_private"
|
||||
return f"SELECT {empty_cols} WHERE 0", {}
|
||||
|
||||
# Build the cascading permission query
|
||||
rules_union = " UNION ALL ".join(rule_sqls)
|
||||
|
||||
# Build the main query
|
||||
query_parts = [
|
||||
"WITH",
|
||||
"base AS (",
|
||||
f" {base_resources_sql}",
|
||||
"),",
|
||||
"all_rules AS (",
|
||||
f" {rules_union}",
|
||||
"),",
|
||||
]
|
||||
|
||||
# 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(
|
||||
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)
|
||||
|
||||
all_params.update(anon_params)
|
||||
|
||||
if anon_sqls_rewritten:
|
||||
anon_rules_union = " UNION ALL ".join(anon_sqls_rewritten)
|
||||
query_parts.extend(
|
||||
[
|
||||
"anon_rules AS (",
|
||||
f" {anon_rules_union}",
|
||||
"),",
|
||||
]
|
||||
)
|
||||
|
||||
# Continue with the cascading logic
|
||||
query_parts.extend(
|
||||
[
|
||||
"child_lvl AS (",
|
||||
" 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,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"parent_lvl AS (",
|
||||
" 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,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"global_lvl AS (",
|
||||
" 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,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
]
|
||||
)
|
||||
|
||||
# Add anonymous decision logic if needed
|
||||
if include_is_private:
|
||||
query_parts.extend(
|
||||
[
|
||||
"anon_child_lvl AS (",
|
||||
" 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",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_parent_lvl AS (",
|
||||
" 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",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_global_lvl AS (",
|
||||
" 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",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_decisions AS (",
|
||||
" SELECT",
|
||||
" b.parent, b.child,",
|
||||
" CASE",
|
||||
" WHEN acl.any_deny = 1 THEN 0",
|
||||
" WHEN acl.any_allow = 1 THEN 1",
|
||||
" WHEN apl.any_deny = 1 THEN 0",
|
||||
" WHEN apl.any_allow = 1 THEN 1",
|
||||
" WHEN agl.any_deny = 1 THEN 0",
|
||||
" WHEN agl.any_allow = 1 THEN 1",
|
||||
" ELSE 0",
|
||||
" END AS anon_is_allowed",
|
||||
" FROM base b",
|
||||
" JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))",
|
||||
" JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))",
|
||||
" JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))",
|
||||
"),",
|
||||
]
|
||||
)
|
||||
|
||||
# Final decisions
|
||||
query_parts.extend(
|
||||
[
|
||||
"decisions AS (",
|
||||
" SELECT",
|
||||
" b.parent, b.child,",
|
||||
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
|
||||
" -- Priority order:",
|
||||
" -- 1. Child-level deny (most specific, blocks access)",
|
||||
" -- 2. Child-level allow (most specific, grants access)",
|
||||
" -- 3. Parent-level deny (intermediate, blocks access)",
|
||||
" -- 4. Parent-level allow (intermediate, grants access)",
|
||||
" -- 5. Global-level deny (least specific, blocks access)",
|
||||
" -- 6. Global-level allow (least specific, grants access)",
|
||||
" -- 7. Default deny (no rules match)",
|
||||
" CASE",
|
||||
" WHEN cl.any_deny = 1 THEN 0",
|
||||
" WHEN cl.any_allow = 1 THEN 1",
|
||||
" WHEN pl.any_deny = 1 THEN 0",
|
||||
" WHEN pl.any_allow = 1 THEN 1",
|
||||
" WHEN gl.any_deny = 1 THEN 0",
|
||||
" WHEN gl.any_allow = 1 THEN 1",
|
||||
" ELSE 0",
|
||||
" END AS is_allowed,",
|
||||
" CASE",
|
||||
" WHEN cl.any_deny = 1 THEN cl.deny_reason",
|
||||
" WHEN cl.any_allow = 1 THEN cl.allow_reason",
|
||||
" WHEN pl.any_deny = 1 THEN pl.deny_reason",
|
||||
" WHEN pl.any_allow = 1 THEN pl.allow_reason",
|
||||
" WHEN gl.any_deny = 1 THEN gl.deny_reason",
|
||||
" WHEN gl.any_allow = 1 THEN gl.allow_reason",
|
||||
" ELSE 'default deny'",
|
||||
" END AS reason",
|
||||
]
|
||||
)
|
||||
|
||||
if include_is_private:
|
||||
query_parts.append(
|
||||
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
|
||||
)
|
||||
|
||||
query_parts.extend(
|
||||
[
|
||||
" FROM base b",
|
||||
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
|
||||
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
|
||||
" JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
|
||||
]
|
||||
)
|
||||
|
||||
if include_is_private:
|
||||
query_parts.append(
|
||||
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
|
||||
)
|
||||
|
||||
query_parts.append(")")
|
||||
|
||||
# Final SELECT
|
||||
select_cols = "parent, child, reason"
|
||||
if include_is_private:
|
||||
select_cols += ", is_private"
|
||||
|
||||
query_parts.append(f"SELECT {select_cols}")
|
||||
query_parts.append("FROM decisions")
|
||||
query_parts.append("WHERE is_allowed = 1")
|
||||
|
||||
# Add parent filter if specified
|
||||
if parent is not None:
|
||||
query_parts.append(" AND parent = :filter_parent")
|
||||
all_params["filter_parent"] = parent
|
||||
|
||||
query_parts.append("ORDER BY parent, child")
|
||||
|
||||
query = "\n".join(query_parts)
|
||||
return query, all_params
|
||||
|
||||
|
||||
async def check_permission_for_resource(
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
action: str,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if an actor has permission for a specific action on a specific resource.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
actor: The actor dict (or None)
|
||||
action: The action name
|
||||
parent: The parent resource identifier (e.g., database name, or None)
|
||||
child: The child resource identifier (e.g., table name, or None)
|
||||
|
||||
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.
|
||||
"""
|
||||
# Get the Action object
|
||||
action_obj = datasette.actions.get(action)
|
||||
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(
|
||||
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)
|
||||
|
||||
# If no rules, default deny
|
||||
if not rule_sqls:
|
||||
return False
|
||||
|
||||
# Build a simplified query that just checks for this one resource
|
||||
rules_union = " UNION ALL ".join(rule_sqls)
|
||||
|
||||
# Add parameters for the resource we're checking
|
||||
all_params["_check_parent"] = parent
|
||||
all_params["_check_child"] = child
|
||||
|
||||
query = f"""
|
||||
WITH
|
||||
all_rules AS (
|
||||
{rules_union}
|
||||
),
|
||||
child_lvl AS (
|
||||
SELECT
|
||||
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
|
||||
FROM all_rules ar
|
||||
WHERE ar.parent = :_check_parent AND ar.child = :_check_child
|
||||
),
|
||||
parent_lvl AS (
|
||||
SELECT
|
||||
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
|
||||
FROM all_rules ar
|
||||
WHERE ar.parent = :_check_parent AND ar.child IS NULL
|
||||
),
|
||||
global_lvl AS (
|
||||
SELECT
|
||||
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
|
||||
FROM all_rules ar
|
||||
WHERE ar.parent IS NULL AND ar.child IS NULL
|
||||
)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN cl.any_deny = 1 THEN 0
|
||||
WHEN cl.any_allow = 1 THEN 1
|
||||
WHEN pl.any_deny = 1 THEN 0
|
||||
WHEN pl.any_allow = 1 THEN 1
|
||||
WHEN gl.any_deny = 1 THEN 0
|
||||
WHEN gl.any_allow = 1 THEN 1
|
||||
ELSE 0
|
||||
END AS is_allowed
|
||||
FROM child_lvl cl, parent_lvl pl, global_lvl gl
|
||||
"""
|
||||
|
||||
# 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
|
||||
|
|
@ -1,31 +1,18 @@
|
|||
# perm_utils.py
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
||||
import json
|
||||
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
||||
import sqlite3
|
||||
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Plugin interface & utilities
|
||||
# -----------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginSQL:
|
||||
"""
|
||||
A plugin contributes SQL that yields:
|
||||
parent TEXT NULL,
|
||||
child TEXT NULL,
|
||||
allow INTEGER, -- 1 allow, 0 deny
|
||||
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)
|
||||
|
||||
|
||||
def _namespace_params(i: int, params: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Rewrite parameter placeholders to distinct names per plugin block.
|
||||
|
|
@ -45,22 +32,20 @@ def _namespace_params(i: int, params: Dict[str, Any]) -> Tuple[str, Dict[str, An
|
|||
return rewrite, namespaced
|
||||
|
||||
|
||||
PluginProvider = Callable[[str], PluginSQL]
|
||||
PluginOrFactory = Union[PluginSQL, PluginProvider]
|
||||
|
||||
|
||||
def build_rules_union(
|
||||
actor: str, plugins: Sequence[PluginSQL]
|
||||
actor: dict | None, plugins: Sequence[PermissionSQL]
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Compose plugin SQL into a UNION ALL with namespaced parameters.
|
||||
|
||||
Returns:
|
||||
union_sql: a SELECT with columns (parent, child, allow, reason, source_plugin)
|
||||
params: dict of bound parameters including :actor and namespaced plugin params
|
||||
params: dict of bound parameters including :actor (JSON), :actor_id, and namespaced plugin params
|
||||
"""
|
||||
parts: List[str] = []
|
||||
params: Dict[str, Any] = {"actor": actor}
|
||||
actor_json = json.dumps(actor) if actor else None
|
||||
actor_id = actor.get("id") if actor else None
|
||||
params: Dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
|
||||
|
||||
for i, p in enumerate(plugins):
|
||||
rewrite, ns_params = _namespace_params(i, p.params)
|
||||
|
|
@ -91,11 +76,11 @@ def build_rules_union(
|
|||
|
||||
async def resolve_permissions_from_catalog(
|
||||
db,
|
||||
actor: str,
|
||||
plugins: Sequence[PluginOrFactory],
|
||||
actor: dict | None,
|
||||
plugins: Sequence[Any],
|
||||
action: str,
|
||||
candidate_sql: str,
|
||||
candidate_params: Optional[Dict[str, Any]] = None,
|
||||
candidate_params: Dict[str, Any] | None = None,
|
||||
*,
|
||||
implicit_deny: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
|
|
@ -107,8 +92,9 @@ async def resolve_permissions_from_catalog(
|
|||
(Use child=NULL for parent-scoped actions like "execute-sql".)
|
||||
- *db* exposes: rows = await db.execute(sql, params)
|
||||
where rows is an iterable of sqlite3.Row
|
||||
- plugins are either PluginSQL objects or callables accepting (action: str)
|
||||
and returning PluginSQL instances selecting (parent, child, allow, reason)
|
||||
- plugins: hook results handled by await_me_maybe - can be sync/async,
|
||||
single PermissionSQL, list, or callable returning PermissionSQL
|
||||
- actor is the actor dict (or None), made available as :actor (JSON), :actor_id, and :action
|
||||
|
||||
Decision policy:
|
||||
1) Specificity first: child (depth=2) > parent (depth=1) > root (depth=0)
|
||||
|
|
@ -121,21 +107,20 @@ async def resolve_permissions_from_catalog(
|
|||
- parent, child, allow, reason, source_plugin, depth
|
||||
- resource (rendered "/parent/child" or "/parent" or "/")
|
||||
"""
|
||||
resolved_plugins: List[PluginSQL] = []
|
||||
resolved_plugins: List[PermissionSQL] = []
|
||||
for plugin in plugins:
|
||||
if callable(plugin) and not isinstance(plugin, PluginSQL):
|
||||
if callable(plugin) and not isinstance(plugin, PermissionSQL):
|
||||
resolved = plugin(action) # type: ignore[arg-type]
|
||||
else:
|
||||
resolved = plugin # type: ignore[assignment]
|
||||
if not isinstance(resolved, PluginSQL):
|
||||
raise TypeError("Plugin providers must return PluginSQL instances")
|
||||
if not isinstance(resolved, PermissionSQL):
|
||||
raise TypeError("Plugin providers must return PermissionSQL instances")
|
||||
resolved_plugins.append(resolved)
|
||||
|
||||
union_sql, rule_params = build_rules_union(actor, resolved_plugins)
|
||||
all_params = {
|
||||
**(candidate_params or {}),
|
||||
**rule_params,
|
||||
"actor": actor,
|
||||
"action": action,
|
||||
}
|
||||
|
||||
|
|
@ -205,9 +190,9 @@ async def resolve_permissions_from_catalog(
|
|||
|
||||
async def resolve_permissions_with_candidates(
|
||||
db,
|
||||
actor: str,
|
||||
plugins: Sequence[PluginOrFactory],
|
||||
candidates: List[Tuple[str, Optional[str]]],
|
||||
actor: dict | None,
|
||||
plugins: Sequence[Any],
|
||||
candidates: List[Tuple[str, str | None]],
|
||||
action: str,
|
||||
*,
|
||||
implicit_deny: bool = True,
|
||||
|
|
@ -217,6 +202,7 @@ async def resolve_permissions_with_candidates(
|
|||
the candidates as a UNION of parameterized SELECTs in a CTE.
|
||||
|
||||
candidates: list of (parent, child) where child can be None for parent-scoped actions.
|
||||
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
|
||||
"""
|
||||
# Build a small CTE for candidates.
|
||||
cand_rows_sql: List[str] = []
|
||||
|
|
|
|||
|
|
@ -71,25 +71,24 @@ class DatabaseView(View):
|
|||
|
||||
metadata = await datasette.get_database_metadata(database)
|
||||
|
||||
sql_views = []
|
||||
for view_name in await db.view_names():
|
||||
view_visible, view_private = await datasette.check_visibility(
|
||||
request.actor,
|
||||
permissions=[
|
||||
("view-table", (database, view_name)),
|
||||
("view-database", database),
|
||||
"view-instance",
|
||||
],
|
||||
)
|
||||
if view_visible:
|
||||
sql_views.append(
|
||||
{
|
||||
"name": view_name,
|
||||
"private": view_private,
|
||||
}
|
||||
)
|
||||
# Get all tables/views this actor can see in bulk with private flag
|
||||
from datasette.resources import TableResource
|
||||
|
||||
tables = await get_tables(datasette, request, db)
|
||||
allowed_tables = await datasette.allowed_resources(
|
||||
"view-table", request.actor, parent=database, include_is_private=True
|
||||
)
|
||||
# Create lookup dict for quick access
|
||||
allowed_dict = {r.child: r for r in allowed_tables}
|
||||
|
||||
# Filter to just views
|
||||
view_names_set = set(await db.view_names())
|
||||
sql_views = [
|
||||
{"name": name, "private": allowed_dict[name].private}
|
||||
for name in allowed_dict
|
||||
if name in view_names_set
|
||||
]
|
||||
|
||||
tables = await get_tables(datasette, request, db, allowed_dict)
|
||||
canned_queries = []
|
||||
for query in (
|
||||
await datasette.get_canned_queries(database, request.actor)
|
||||
|
|
@ -332,7 +331,16 @@ class QueryContext(Context):
|
|||
)
|
||||
|
||||
|
||||
async def get_tables(datasette, request, db):
|
||||
async def get_tables(datasette, request, db, allowed_dict):
|
||||
"""
|
||||
Get list of tables with metadata for the database view.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
request: The current request
|
||||
db: The database
|
||||
allowed_dict: Dict mapping table name -> Resource object with .private attribute
|
||||
"""
|
||||
tables = []
|
||||
database = db.name
|
||||
table_counts = await db.table_counts(100)
|
||||
|
|
@ -340,16 +348,9 @@ async def get_tables(datasette, request, db):
|
|||
all_foreign_keys = await db.get_all_foreign_keys()
|
||||
|
||||
for table in table_counts:
|
||||
table_visible, table_private = await datasette.check_visibility(
|
||||
request.actor,
|
||||
permissions=[
|
||||
("view-table", (database, table)),
|
||||
("view-database", database),
|
||||
"view-instance",
|
||||
],
|
||||
)
|
||||
if not table_visible:
|
||||
if table not in allowed_dict:
|
||||
continue
|
||||
|
||||
table_columns = await db.table_columns(table)
|
||||
tables.append(
|
||||
{
|
||||
|
|
@ -360,7 +361,7 @@ async def get_tables(datasette, request, db):
|
|||
"hidden": table in hidden_table_names,
|
||||
"fts_table": await db.fts_table(table),
|
||||
"foreign_keys": all_foreign_keys[table],
|
||||
"private": table_private,
|
||||
"private": allowed_dict[table].private,
|
||||
}
|
||||
)
|
||||
tables.sort(key=lambda t: (t["hidden"], t["name"]))
|
||||
|
|
@ -509,6 +510,15 @@ class QueryView(View):
|
|||
db = await datasette.resolve_database(request)
|
||||
database = db.name
|
||||
|
||||
# Get all tables/views this actor can see in bulk with private flag
|
||||
from datasette.resources import TableResource
|
||||
|
||||
allowed_tables = await datasette.allowed_resources(
|
||||
"view-table", request.actor, parent=database, include_is_private=True
|
||||
)
|
||||
# Create lookup dict for quick access
|
||||
allowed_dict = {r.child: r for r in allowed_tables}
|
||||
|
||||
# Are we a canned query?
|
||||
canned_query = None
|
||||
canned_query_write = False
|
||||
|
|
@ -808,7 +818,7 @@ class QueryView(View):
|
|||
show_hide_text=show_hide_text,
|
||||
editable=not canned_query,
|
||||
allow_execute_sql=allow_execute_sql,
|
||||
tables=await get_tables(datasette, request, db),
|
||||
tables=await get_tables(datasette, request, db, allowed_dict),
|
||||
named_parameter_values=named_parameter_values,
|
||||
edit_sql_url=edit_sql_url,
|
||||
display_rows=await display_rows(
|
||||
|
|
|
|||
|
|
@ -26,27 +26,47 @@ class IndexView(BaseView):
|
|||
async def get(self, request):
|
||||
as_format = request.url_vars["format"]
|
||||
await self.ds.ensure_permissions(request.actor, ["view-instance"])
|
||||
|
||||
# Get all allowed databases and tables in bulk
|
||||
allowed_databases = await self.ds.allowed_resources(
|
||||
"view-database", request.actor, include_is_private=True
|
||||
)
|
||||
allowed_db_dict = {r.parent: r for r in allowed_databases}
|
||||
|
||||
allowed_tables = await self.ds.allowed_resources(
|
||||
"view-table", request.actor, include_is_private=True
|
||||
)
|
||||
# Group by database
|
||||
tables_by_db = {}
|
||||
for t in allowed_tables:
|
||||
if t.parent not in tables_by_db:
|
||||
tables_by_db[t.parent] = {}
|
||||
tables_by_db[t.parent][t.child] = t
|
||||
|
||||
databases = []
|
||||
for name, db in self.ds.databases.items():
|
||||
database_visible, database_private = await self.ds.check_visibility(
|
||||
request.actor,
|
||||
"view-database",
|
||||
name,
|
||||
)
|
||||
if not database_visible:
|
||||
continue
|
||||
table_names = await db.table_names()
|
||||
# Iterate over allowed databases instead of all databases
|
||||
for name in allowed_db_dict.keys():
|
||||
db = self.ds.databases[name]
|
||||
database_private = allowed_db_dict[name].private
|
||||
|
||||
# Get allowed tables/views for this database
|
||||
allowed_for_db = tables_by_db.get(name, {})
|
||||
|
||||
# Get table names from allowed set instead of db.table_names()
|
||||
table_names = [child_name for child_name in allowed_for_db.keys()]
|
||||
|
||||
hidden_table_names = set(await db.hidden_table_names())
|
||||
|
||||
views = []
|
||||
for view_name in await db.view_names():
|
||||
view_visible, view_private = await self.ds.check_visibility(
|
||||
request.actor,
|
||||
"view-table",
|
||||
(name, view_name),
|
||||
)
|
||||
if view_visible:
|
||||
views.append({"name": view_name, "private": view_private})
|
||||
# Determine which allowed items are views
|
||||
view_names_set = set(await db.view_names())
|
||||
views = [
|
||||
{"name": child_name, "private": resource.private}
|
||||
for child_name, resource in allowed_for_db.items()
|
||||
if child_name in view_names_set
|
||||
]
|
||||
|
||||
# Filter to just tables (not views) for table processing
|
||||
table_names = [name for name in table_names if name not in view_names_set]
|
||||
|
||||
# Perform counts only for immutable or DBS with <= COUNT_TABLE_LIMIT tables
|
||||
table_counts = {}
|
||||
|
|
@ -58,13 +78,10 @@ class IndexView(BaseView):
|
|||
|
||||
tables = {}
|
||||
for table in table_names:
|
||||
visible, private = await self.ds.check_visibility(
|
||||
request.actor,
|
||||
"view-table",
|
||||
(name, table),
|
||||
)
|
||||
if not visible:
|
||||
# Check if table is in allowed set
|
||||
if table not in allowed_for_db:
|
||||
continue
|
||||
|
||||
table_columns = await db.table_columns(table)
|
||||
tables[table] = {
|
||||
"name": table,
|
||||
|
|
@ -74,7 +91,7 @@ class IndexView(BaseView):
|
|||
"hidden": table in hidden_table_names,
|
||||
"fts_table": await db.fts_table(table),
|
||||
"num_relationships_for_sorting": 0,
|
||||
"private": private,
|
||||
"private": allowed_for_db[table].private,
|
||||
}
|
||||
|
||||
if request.args.get("_sort") == "relationships" or not table_counts:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ from datasette.utils import (
|
|||
tilde_encode,
|
||||
tilde_decode,
|
||||
)
|
||||
from datasette.utils.permissions import PluginSQL, resolve_permissions_from_catalog
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.utils.permissions import resolve_permissions_from_catalog
|
||||
from datasette.plugins import pm
|
||||
from .base import BaseView, View
|
||||
import secrets
|
||||
|
|
@ -237,6 +238,7 @@ class AllowedResourcesView(BaseView):
|
|||
)
|
||||
|
||||
actor = request.actor if isinstance(request.actor, dict) else None
|
||||
actor_id = actor.get("id") if actor else None
|
||||
parent_filter = request.args.get("parent")
|
||||
child_filter = request.args.get("child")
|
||||
if child_filter and not parent_filter:
|
||||
|
|
@ -303,18 +305,11 @@ class AllowedResourcesView(BaseView):
|
|||
for candidate in candidates:
|
||||
if candidate is None:
|
||||
continue
|
||||
if not isinstance(candidate, PluginSQL):
|
||||
logger.warning(
|
||||
"Skipping permission_resources_sql result %r from plugin; expected PluginSQL",
|
||||
candidate,
|
||||
)
|
||||
continue
|
||||
plugins.append(candidate)
|
||||
|
||||
actor_id = actor.get("id") if actor else None
|
||||
rows = await resolve_permissions_from_catalog(
|
||||
db,
|
||||
actor=str(actor_id) if actor_id is not None else "",
|
||||
actor=actor,
|
||||
plugins=plugins,
|
||||
action=action,
|
||||
candidate_sql=candidate_sql,
|
||||
|
|
@ -424,7 +419,9 @@ class PermissionRulesView(BaseView):
|
|||
page_size = max_page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
union_sql, union_params = await self.ds.allowed_resources_sql(actor, action)
|
||||
union_sql, union_params = await self.ds._build_permission_rules_sql(
|
||||
actor, action
|
||||
)
|
||||
await self.ds.refresh_schemas()
|
||||
db = self.ds.get_internal_database()
|
||||
|
||||
|
|
@ -923,3 +920,62 @@ class ApiExplorerView(BaseView):
|
|||
"private": private,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TablesView(BaseView):
|
||||
"""
|
||||
Simple endpoint that uses the new allowed_resources() API.
|
||||
Returns JSON list of all tables the actor can view.
|
||||
|
||||
Supports ?q=foo+bar to filter tables matching .*foo.*bar.* pattern,
|
||||
ordered by shortest name first.
|
||||
"""
|
||||
|
||||
name = "tables"
|
||||
has_json_alternate = False
|
||||
|
||||
async def get(self, request):
|
||||
# Get search query parameter
|
||||
q = request.args.get("q", "").strip()
|
||||
|
||||
# Only return matches if there's a non-empty search query
|
||||
if not q:
|
||||
return Response.json({"matches": []})
|
||||
|
||||
# Build SQL LIKE pattern from search terms
|
||||
# Split search terms by whitespace and build pattern: %term1%term2%term3%
|
||||
terms = q.split()
|
||||
pattern = "%" + "%".join(terms) + "%"
|
||||
|
||||
# Get SQL for allowed resources using the permission system
|
||||
permission_sql, params = await self.ds.allowed_resources_sql(
|
||||
action="view-table", actor=request.actor
|
||||
)
|
||||
|
||||
# Build query with CTE to filter by search pattern
|
||||
sql = f"""
|
||||
WITH allowed_tables AS (
|
||||
{permission_sql}
|
||||
)
|
||||
SELECT parent, child
|
||||
FROM allowed_tables
|
||||
WHERE child LIKE :pattern COLLATE NOCASE
|
||||
ORDER BY length(child), child
|
||||
"""
|
||||
|
||||
# Merge params from permission SQL with our pattern param
|
||||
all_params = {**params, "pattern": pattern}
|
||||
|
||||
# Execute against internal database
|
||||
result = await self.ds.get_internal_database().execute(sql, all_params)
|
||||
|
||||
# Build response
|
||||
matches = [
|
||||
{
|
||||
"name": f"{row['parent']}: {row['child']}",
|
||||
"url": self.ds.urls.table(row["parent"], row["child"]),
|
||||
}
|
||||
for row in result.rows
|
||||
]
|
||||
|
||||
return Response.json({"matches": matches})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,17 @@ Using the "root" actor
|
|||
|
||||
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.
|
||||
|
||||
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. This provides access to a small number of debugging features.
|
||||
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user has **all permissions** - they can perform any action regardless of other permission rules.
|
||||
|
||||
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
|
||||
|
||||
* All view permissions (view-instance, view-database, view-table, etc.)
|
||||
* All write permissions (insert-row, update-row, delete-row, create-table, alter-table, drop-table)
|
||||
* Debug permissions (permissions-debug, debug-menu)
|
||||
* Any custom permissions defined by plugins
|
||||
|
||||
.. warning::
|
||||
The ``--root`` flag should only be used for local development. Never use it in production or on publicly accessible servers.
|
||||
|
||||
To sign in as root, start Datasette using the ``--root`` command-line option, like this::
|
||||
|
||||
|
|
@ -1091,7 +1101,7 @@ This endpoint provides an interactive HTML form interface. Add ``.json`` to the
|
|||
|
||||
Pass ``?action=`` as a query parameter to specify which action to check.
|
||||
|
||||
**Requires the permissions-debug permission** - this endpoint returns a 403 Forbidden error for users without this permission. The :ref:`root user <authentication_root>` has this permission by default.
|
||||
**Requires the permissions-debug permission** - this endpoint returns a 403 Forbidden error for users without this permission.
|
||||
|
||||
.. _PermissionCheckView:
|
||||
|
||||
|
|
|
|||
|
|
@ -369,6 +369,53 @@ If neither ``metadata.json`` nor any of the plugins provide an answer to the per
|
|||
|
||||
See :ref:`permissions` for a full list of permission actions included in Datasette core.
|
||||
|
||||
.. _datasette_allowed:
|
||||
|
||||
await .allowed(\*, action, resource, actor=None)
|
||||
------------------------------------------------
|
||||
|
||||
``action`` - string
|
||||
The name of the action that is being permission checked.
|
||||
|
||||
``resource`` - Resource object
|
||||
A Resource object representing the database, table, or other resource. Must be an instance of a Resource class such as ``TableResource``, ``DatabaseResource``, ``QueryResource``, or ``InstanceResource``.
|
||||
|
||||
``actor`` - dictionary, optional
|
||||
The authenticated actor. This is usually ``request.actor``. Defaults to ``None`` for unauthenticated requests.
|
||||
|
||||
This method checks if the given actor has permission to perform the given action on the given resource. All parameters must be passed as keyword arguments.
|
||||
|
||||
This is the modern resource-based permission checking method. It works with Resource objects that provide structured information about what is being accessed.
|
||||
|
||||
Example usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette.resources import (
|
||||
TableResource,
|
||||
DatabaseResource,
|
||||
)
|
||||
|
||||
# Check if actor can view a specific table
|
||||
can_view = await datasette.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource(
|
||||
database="fixtures", table="facetable"
|
||||
),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
# Check if actor can execute SQL on a database
|
||||
can_execute = await datasette.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(database="fixtures"),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
The method returns ``True`` if the permission is granted, ``False`` if denied.
|
||||
|
||||
For legacy string/tuple based permission checking, use :ref:`datasette_permission_allowed` instead.
|
||||
|
||||
.. _datasette_ensure_permissions:
|
||||
|
||||
await .ensure_permissions(actor, permissions)
|
||||
|
|
@ -1001,6 +1048,132 @@ Use the ``format="json"`` (or ``"csv"`` or other formats supported by plugins) a
|
|||
|
||||
These methods each return a ``datasette.utils.PrefixedUrlString`` object, which is a subclass of the Python ``str`` type. This allows the logic that considers the ``base_url`` setting to detect if that prefix has already been applied to the path.
|
||||
|
||||
.. _internals_permission_classes:
|
||||
|
||||
Permission classes and utilities
|
||||
=================================
|
||||
|
||||
.. _internals_permission_sql:
|
||||
|
||||
PermissionSQL class
|
||||
-------------------
|
||||
|
||||
The ``PermissionSQL`` class is used by plugins to contribute SQL-based permission rules through the :ref:`plugin_hook_permission_resources_sql` hook. This enables efficient permission checking across multiple resources by leveraging SQLite's query engine.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionSQL:
|
||||
source: str # Plugin name for auditing
|
||||
sql: str # SQL query returning permission rules
|
||||
params: Dict[str, Any] # Parameters for the SQL query
|
||||
|
||||
**Attributes:**
|
||||
|
||||
``source`` - string
|
||||
An identifier for the source of these permission rules, typically the plugin name. This is used for debugging and auditing.
|
||||
|
||||
``sql`` - string
|
||||
A SQL query that returns permission rules. The query must return rows with the following columns:
|
||||
|
||||
- ``parent`` (TEXT or NULL) - The parent resource identifier (e.g., database name)
|
||||
- ``child`` (TEXT or NULL) - The child resource identifier (e.g., table name)
|
||||
- ``allow`` (INTEGER) - 1 for allow, 0 for deny
|
||||
- ``reason`` (TEXT) - A human-readable explanation of why this permission was granted or denied
|
||||
|
||||
``params`` - dictionary
|
||||
A dictionary of parameters to bind into the SQL query. Parameter names should not include the ``:`` prefix.
|
||||
|
||||
.. _permission_sql_parameters:
|
||||
|
||||
Available SQL parameters
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
When writing SQL for ``PermissionSQL``, the following parameters are automatically available:
|
||||
|
||||
``:actor`` - JSON string or NULL
|
||||
The full actor dictionary serialized as JSON. Use SQLite's ``json_extract()`` function to access fields:
|
||||
|
||||
.. code-block:: sql
|
||||
|
||||
json_extract(:actor, '$.role') = 'admin'
|
||||
json_extract(:actor, '$.team') = 'engineering'
|
||||
|
||||
``:actor_id`` - string or NULL
|
||||
The actor's ``id`` field, for simple equality comparisons:
|
||||
|
||||
.. code-block:: sql
|
||||
|
||||
:actor_id = 'alice'
|
||||
|
||||
``:action`` - string
|
||||
The action being checked (e.g., ``"view-table"``, ``"insert-row"``, ``"execute-sql"``).
|
||||
|
||||
**Example usage:**
|
||||
|
||||
Here's an example plugin that grants view-table permissions to users with an "analyst" role for tables in the "analytics" database:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
@hookimpl
|
||||
def permission_resources_sql(datasette, actor, action):
|
||||
if action != "view-table":
|
||||
return None
|
||||
|
||||
return PermissionSQL(
|
||||
source="my_analytics_plugin",
|
||||
sql="""
|
||||
SELECT 'analytics' AS parent,
|
||||
NULL AS child,
|
||||
1 AS allow,
|
||||
'Analysts can view analytics database' AS reason
|
||||
WHERE json_extract(:actor, '$.role') = 'analyst'
|
||||
AND :action = 'view-table'
|
||||
""",
|
||||
params={},
|
||||
)
|
||||
|
||||
A more complex example that uses custom parameters:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@hookimpl
|
||||
def permission_resources_sql(datasette, actor, action):
|
||||
if not actor:
|
||||
return None
|
||||
|
||||
user_teams = actor.get("teams", [])
|
||||
|
||||
return PermissionSQL(
|
||||
source="team_permissions_plugin",
|
||||
sql="""
|
||||
SELECT
|
||||
team_database AS parent,
|
||||
team_table AS child,
|
||||
1 AS allow,
|
||||
'User is member of team: ' || team_name AS reason
|
||||
FROM team_permissions
|
||||
WHERE user_id = :user_id
|
||||
AND :action IN ('view-table', 'insert-row', 'update-row')
|
||||
""",
|
||||
params={"user_id": actor.get("id")},
|
||||
)
|
||||
|
||||
**Permission resolution rules:**
|
||||
|
||||
When multiple ``PermissionSQL`` objects return conflicting rules for the same resource, Datasette applies the following precedence:
|
||||
|
||||
1. **Specificity**: Child-level rules (with both ``parent`` and ``child``) override parent-level rules (with only ``parent``), which override root-level rules (with neither ``parent`` nor ``child``)
|
||||
2. **Deny over allow**: At the same specificity level, deny (``allow=0``) takes precedence over allow (``allow=1``)
|
||||
3. **Implicit deny**: If no rules match a resource, access is denied by default
|
||||
|
||||
.. _internals_database:
|
||||
|
||||
Database class
|
||||
|
|
|
|||
|
|
@ -144,6 +144,47 @@ Shows currently attached databases. `Databases example <https://latest.datasette
|
|||
}
|
||||
]
|
||||
|
||||
.. _TablesView:
|
||||
|
||||
/-/tables
|
||||
---------
|
||||
|
||||
Returns a JSON list of all tables that the current actor has permission to view. This endpoint uses the resource-based permission system and respects database and table-level access controls.
|
||||
|
||||
The endpoint supports a ``?q=`` query parameter for filtering tables by name using case-insensitive regex matching.
|
||||
|
||||
`Tables example <https://latest.datasette.io/-/tables>`_:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"name": "fixtures/facetable",
|
||||
"url": "/fixtures/facetable"
|
||||
},
|
||||
{
|
||||
"name": "fixtures/searchable",
|
||||
"url": "/fixtures/searchable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Search example with ``?q=facet`` returns only tables matching ``.*facet.*``:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"name": "fixtures/facetable",
|
||||
"url": "/fixtures/facetable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
When multiple search terms are provided (e.g., ``?q=user+profile``), tables must match the pattern ``.*user.*profile.*``. Results are ordered by shortest table name first.
|
||||
|
||||
.. _JsonDataView_threads:
|
||||
|
||||
/-/threads
|
||||
|
|
|
|||
|
|
@ -782,6 +782,9 @@ The plugin hook can then be used to register the new facet class like this:
|
|||
register_permissions(datasette)
|
||||
--------------------------------
|
||||
|
||||
.. note::
|
||||
This hook is deprecated. Use :ref:`plugin_register_actions` instead, which provides a more flexible resource-based permission system.
|
||||
|
||||
If your plugin needs to register additional permissions unique to that plugin - ``upload-csvs`` for example - you can return a list of those permissions from this hook.
|
||||
|
||||
.. code-block:: python
|
||||
|
|
@ -824,6 +827,141 @@ The fields of the ``Permission`` class are as follows:
|
|||
|
||||
This should only be ``True`` if you want anonymous users to be able to take this action.
|
||||
|
||||
.. _plugin_register_actions:
|
||||
|
||||
register_actions(datasette)
|
||||
----------------------------
|
||||
|
||||
If your plugin needs to register actions that can be checked with Datasette's new resource-based permission system, return a list of those actions from this hook.
|
||||
|
||||
Actions define what operations can be performed on resources (like viewing a table, executing SQL, or custom plugin actions).
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import Action, Resource
|
||||
|
||||
|
||||
class DocumentCollectionResource(Resource):
|
||||
"""A collection of documents."""
|
||||
|
||||
name = "document-collection"
|
||||
parent_name = None
|
||||
|
||||
def __init__(self, collection: str):
|
||||
super().__init__(parent=collection, child=None)
|
||||
|
||||
@classmethod
|
||||
def resources_sql(cls) -> str:
|
||||
return """
|
||||
SELECT collection_name AS parent, NULL AS child
|
||||
FROM document_collections
|
||||
"""
|
||||
|
||||
|
||||
class DocumentResource(Resource):
|
||||
"""A document in a collection."""
|
||||
|
||||
name = "document"
|
||||
parent_name = "document-collection"
|
||||
|
||||
def __init__(self, collection: str, document: str):
|
||||
super().__init__(parent=collection, child=document)
|
||||
|
||||
@classmethod
|
||||
def resources_sql(cls) -> str:
|
||||
return """
|
||||
SELECT collection_name AS parent, document_id AS child
|
||||
FROM documents
|
||||
"""
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_actions(datasette):
|
||||
return [
|
||||
Action(
|
||||
name="list-documents",
|
||||
abbr="ld",
|
||||
description="List documents in a collection",
|
||||
takes_parent=True,
|
||||
takes_child=False,
|
||||
resource_class=DocumentCollectionResource,
|
||||
),
|
||||
Action(
|
||||
name="view-document",
|
||||
abbr="vdoc",
|
||||
description="View document",
|
||||
takes_parent=True,
|
||||
takes_child=True,
|
||||
resource_class=DocumentResource,
|
||||
),
|
||||
Action(
|
||||
name="edit-document",
|
||||
abbr="edoc",
|
||||
description="Edit document",
|
||||
takes_parent=True,
|
||||
takes_child=True,
|
||||
resource_class=DocumentResource,
|
||||
),
|
||||
]
|
||||
|
||||
The fields of the ``Action`` dataclass are as follows:
|
||||
|
||||
``name`` - string
|
||||
The name of the action, e.g. ``view-document``. This should be unique across all plugins.
|
||||
|
||||
``abbr`` - string or None
|
||||
An abbreviation of the action, e.g. ``vdoc``. This is optional. Since this needs to be unique across all installed plugins it's best to choose carefully or use ``None``.
|
||||
|
||||
``description`` - string or None
|
||||
A human-readable description of what the action allows you to do.
|
||||
|
||||
``takes_parent`` - boolean
|
||||
``True`` if this action requires a parent identifier (like a database name).
|
||||
|
||||
``takes_child`` - boolean
|
||||
``True`` if this action requires a child identifier (like a table or document name).
|
||||
|
||||
``resource_class`` - type[Resource]
|
||||
The Resource subclass that defines what kind of resource this action applies to. Your Resource subclass must:
|
||||
|
||||
- Define a ``name`` class attribute (e.g., ``"document"``)
|
||||
- Optionally define a ``parent_name`` class attribute (e.g., ``"collection"``)
|
||||
- Implement a ``resources_sql()`` classmethod that returns SQL returning all resources as ``(parent, child)`` columns
|
||||
- Have an ``__init__`` method that accepts appropriate parameters and calls ``super().__init__(parent=..., child=...)``
|
||||
|
||||
The ``resources_sql()`` method
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The ``resources_sql()`` classmethod is crucial to Datasette's permission system. It returns a SQL query that lists all resources of that type that exist in the system.
|
||||
|
||||
This SQL query is used by Datasette to efficiently check permissions across multiple resources at once. When a user requests a list of resources (like tables, documents, or other entities), Datasette uses this SQL to:
|
||||
|
||||
1. Get all resources of this type from your data catalog
|
||||
2. Combine it with permission rules from the ``permission_resources_sql`` hook
|
||||
3. Use SQL joins and filtering to determine which resources the actor can access
|
||||
4. Return only the permitted resources
|
||||
|
||||
The SQL query **must** return exactly two columns:
|
||||
|
||||
- ``parent`` - The parent identifier (e.g., database name, collection name), or ``NULL`` for top-level resources
|
||||
- ``child`` - The child identifier (e.g., table name, document ID), or ``NULL`` for parent-only resources
|
||||
|
||||
For example, if you're building a document management plugin with collections and documents stored in a ``documents`` table, your ``resources_sql()`` might look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@classmethod
|
||||
def resources_sql(cls) -> str:
|
||||
return """
|
||||
SELECT collection_name AS parent, document_id AS child
|
||||
FROM documents
|
||||
"""
|
||||
|
||||
This tells Datasette "here's how to find all documents in the system - look in the documents table and get the collection name and document ID for each one."
|
||||
|
||||
The permission system then uses this query along with rules from plugins to determine which documents each user can access, all efficiently in SQL rather than loading everything into Python.
|
||||
|
||||
.. _plugin_asgi_wrapper:
|
||||
|
||||
asgi_wrapper(datasette)
|
||||
|
|
@ -1307,7 +1445,7 @@ Example: `datasette-permissions-sql <https://datasette.io/plugins/datasette-perm
|
|||
.. _plugin_hook_permission_resources_sql:
|
||||
|
||||
permission_resources_sql(datasette, actor, action)
|
||||
-------------------------------------------------
|
||||
---------------------------------------------------
|
||||
|
||||
``datasette`` - :ref:`internals_datasette`
|
||||
Access to the Datasette instance.
|
||||
|
|
|
|||
|
|
@ -198,6 +198,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
|
|||
"register_output_renderer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "datasette.default_actions",
|
||||
"static": false,
|
||||
"templates": false,
|
||||
"version": null,
|
||||
"hooks": [
|
||||
"register_actions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "datasette.default_magic_parameters",
|
||||
"static": false,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ EXPECTED_PLUGINS = [
|
|||
"prepare_connection",
|
||||
"prepare_jinja2_environment",
|
||||
"query_actions",
|
||||
"register_actions",
|
||||
"register_facet_classes",
|
||||
"register_magic_parameters",
|
||||
"register_permissions",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import asyncio
|
|||
from datasette import hookimpl, Permission
|
||||
from datasette.facets import Facet
|
||||
from datasette import tracer
|
||||
from datasette.permissions import Action
|
||||
from datasette.resources import DatabaseResource
|
||||
from datasette.utils import path_with_added_args
|
||||
from datasette.utils.asgi import asgi_send_json, Response
|
||||
import base64
|
||||
|
|
@ -498,3 +500,17 @@ def register_permissions(datasette):
|
|||
for p in extras["permissions"]
|
||||
)
|
||||
return permissions
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_actions(datasette):
|
||||
return [
|
||||
Action(
|
||||
name="view-collection",
|
||||
abbr="vc",
|
||||
description="View a collection",
|
||||
takes_parent=True,
|
||||
takes_child=False,
|
||||
resource_class=DatabaseResource,
|
||||
)
|
||||
]
|
||||
|
|
|
|||
369
tests/test_actions_sql.py
Normal file
369
tests/test_actions_sql.py
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
"""
|
||||
Tests for the new Resource-based permission system.
|
||||
|
||||
These tests verify:
|
||||
1. The new Datasette.allowed_resources() method
|
||||
2. The new Datasette.allowed() method
|
||||
3. The new Datasette.allowed_resources_with_reasons() method
|
||||
4. That SQL does the heavy lifting (no Python filtering)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from datasette.app import Datasette
|
||||
from datasette.plugins import pm
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.resources import TableResource
|
||||
from datasette import hookimpl
|
||||
|
||||
|
||||
# Test plugin that provides permission rules
|
||||
class PermissionRulesPlugin:
|
||||
def __init__(self, rules_callback):
|
||||
self.rules_callback = rules_callback
|
||||
|
||||
@hookimpl
|
||||
def permission_resources_sql(self, datasette, actor, action):
|
||||
"""Return permission rules based on the callback"""
|
||||
return self.rules_callback(datasette, actor, action)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_ds():
|
||||
"""Create a test Datasette instance with sample data"""
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Add test databases with some tables
|
||||
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 db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS sensitive (id INTEGER PRIMARY KEY)"
|
||||
)
|
||||
|
||||
db2 = ds.add_memory_database("production")
|
||||
await db2.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY)"
|
||||
)
|
||||
await db2.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY)"
|
||||
)
|
||||
|
||||
# Refresh schemas to populate catalog_tables in internal database
|
||||
await ds._refresh_schemas()
|
||||
|
||||
return ds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_resources_global_allow(test_ds):
|
||||
"""Test allowed_resources() with a global allow rule"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("id") == "alice":
|
||||
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 None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
# Use the new allowed_resources() method
|
||||
tables = await test_ds.allowed_resources("view-table", {"id": "alice"})
|
||||
|
||||
# Alice should see all tables
|
||||
assert len(tables) == 5
|
||||
assert all(isinstance(t, TableResource) for t in tables)
|
||||
|
||||
# Check specific tables are present
|
||||
table_set = set((t.parent, t.child) for t in tables)
|
||||
assert ("analytics", "events") in table_set
|
||||
assert ("analytics", "users") in table_set
|
||||
assert ("analytics", "sensitive") in table_set
|
||||
assert ("production", "customers") in table_set
|
||||
assert ("production", "orders") in table_set
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_specific_resource(test_ds):
|
||||
"""Test allowed() method checks specific resource efficiently"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("role") == "analyst":
|
||||
# Allow analytics database, deny everything else (global deny)
|
||||
sql = """
|
||||
SELECT NULL AS parent, NULL AS child, 0 AS allow, 'global deny' AS reason
|
||||
UNION ALL
|
||||
SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason
|
||||
"""
|
||||
return PermissionSQL(source="test", sql=sql, params={})
|
||||
return None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
actor = {"id": "bob", "role": "analyst"}
|
||||
|
||||
# Check specific resources using allowed()
|
||||
# This should use SQL WHERE clause, not fetch all resources
|
||||
assert await test_ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor=actor,
|
||||
)
|
||||
assert await test_ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("analytics", "events"),
|
||||
actor=actor,
|
||||
)
|
||||
assert not await test_ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("production", "orders"),
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_resources_with_reasons(test_ds):
|
||||
"""Test allowed_resources_with_reasons() exposes debugging info"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("role") == "analyst":
|
||||
sql = """
|
||||
SELECT 'analytics' AS parent, NULL AS child, 1 AS allow,
|
||||
'parent: analyst access to analytics' AS reason
|
||||
UNION ALL
|
||||
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,
|
||||
'child: sensitive data denied' AS reason
|
||||
"""
|
||||
return PermissionSQL(source="test", sql=sql, params={})
|
||||
return None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
# Use allowed_resources_with_reasons to get debugging info
|
||||
allowed = await test_ds.allowed_resources_with_reasons(
|
||||
"view-table", {"id": "bob", "role": "analyst"}
|
||||
)
|
||||
|
||||
# Should get analytics tables except sensitive
|
||||
assert len(allowed) >= 2 # At least users and events
|
||||
|
||||
# Check we can access both resource and reason
|
||||
for item in allowed:
|
||||
assert isinstance(item.resource, TableResource)
|
||||
assert isinstance(item.reason, str)
|
||||
if item.resource.parent == "analytics":
|
||||
# Should mention parent-level reason
|
||||
assert "analyst access" in item.reason.lower()
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_child_deny_overrides_parent_allow(test_ds):
|
||||
"""Test that child-level DENY beats parent-level ALLOW"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("role") == "analyst":
|
||||
sql = """
|
||||
SELECT 'analytics' AS parent, NULL AS child, 1 AS allow,
|
||||
'parent: allow analytics' AS reason
|
||||
UNION ALL
|
||||
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow,
|
||||
'child: deny sensitive' AS reason
|
||||
"""
|
||||
return PermissionSQL(source="test", sql=sql, params={})
|
||||
return None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
actor = {"id": "bob", "role": "analyst"}
|
||||
tables = await test_ds.allowed_resources("view-table", actor)
|
||||
|
||||
# Should see analytics tables except sensitive
|
||||
analytics_tables = [t for t in tables if t.parent == "analytics"]
|
||||
assert len(analytics_tables) >= 2
|
||||
|
||||
table_names = {t.child for t in analytics_tables}
|
||||
assert "users" in table_names
|
||||
assert "events" in table_names
|
||||
assert "sensitive" not in table_names
|
||||
|
||||
# Verify with allowed() method
|
||||
assert await test_ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor=actor,
|
||||
)
|
||||
assert not await test_ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("analytics", "sensitive"),
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_child_allow_overrides_parent_deny(test_ds):
|
||||
"""Test that child-level ALLOW beats parent-level DENY"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("id") == "carol":
|
||||
sql = """
|
||||
SELECT 'production' AS parent, NULL AS child, 0 AS allow,
|
||||
'parent: deny production' AS reason
|
||||
UNION ALL
|
||||
SELECT 'production' AS parent, 'orders' AS child, 1 AS allow,
|
||||
'child: carol can see orders' AS reason
|
||||
"""
|
||||
return PermissionSQL(source="test", sql=sql, params={})
|
||||
return None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
actor = {"id": "carol"}
|
||||
tables = await test_ds.allowed_resources("view-table", actor)
|
||||
|
||||
# Should only see production.orders
|
||||
production_tables = [t for t in tables if t.parent == "production"]
|
||||
assert len(production_tables) == 1
|
||||
assert production_tables[0].child == "orders"
|
||||
|
||||
# Verify with allowed() method
|
||||
assert await test_ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("production", "orders"),
|
||||
actor=actor,
|
||||
)
|
||||
assert not await test_ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("production", "customers"),
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_does_filtering_not_python(test_ds):
|
||||
"""
|
||||
Verify that allowed() uses SQL WHERE clause, not Python filtering.
|
||||
|
||||
This test doesn't actually verify the SQL itself (that would require
|
||||
query introspection), but it demonstrates the API contract.
|
||||
"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
# Deny everything by default, allow only analytics.users specifically
|
||||
sql = """
|
||||
SELECT NULL AS parent, NULL AS child, 0 AS allow,
|
||||
'global deny' AS reason
|
||||
UNION ALL
|
||||
SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow,
|
||||
'specific allow' AS reason
|
||||
"""
|
||||
return PermissionSQL(source="test", sql=sql, params={})
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
actor = {"id": "dave"}
|
||||
|
||||
# allowed() should execute a targeted SQL query
|
||||
# NOT fetch all resources and filter in Python
|
||||
assert await test_ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("analytics", "users"),
|
||||
actor=actor,
|
||||
)
|
||||
assert not await test_ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("analytics", "events"),
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
# allowed_resources() should also use SQL filtering
|
||||
tables = await test_ds.allowed_resources("view-table", actor)
|
||||
assert len(tables) == 1
|
||||
assert tables[0].parent == "analytics"
|
||||
assert tables[0].child == "users"
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_permission_rules_returns_correct_schema():
|
||||
"""
|
||||
Test that when no permission rules exist, the empty result has correct schema.
|
||||
|
||||
This is a regression test for a bug where the empty result returned only
|
||||
2 columns (parent, child) instead of the documented 3 columns
|
||||
(parent, child, reason), causing schema mismatches.
|
||||
|
||||
See: https://github.com/simonw/datasette/pull/2515#discussion_r2457803901
|
||||
"""
|
||||
from datasette.utils.actions_sql import build_allowed_resources_sql
|
||||
|
||||
# Create a fresh datasette instance
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Add a test database
|
||||
db = ds.add_memory_database("testdb")
|
||||
await db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY)"
|
||||
)
|
||||
await ds._refresh_schemas()
|
||||
|
||||
# Temporarily block all permission_resources_sql hooks to simulate no rules
|
||||
original_hook = pm.hook.permission_resources_sql
|
||||
|
||||
def empty_hook(*args, **kwargs):
|
||||
return []
|
||||
|
||||
pm.hook.permission_resources_sql = empty_hook
|
||||
|
||||
try:
|
||||
# Call build_allowed_resources_sql directly which will hit the no-rules code path
|
||||
sql, params = await build_allowed_resources_sql(
|
||||
ds, actor={"id": "nobody"}, action="view-table"
|
||||
)
|
||||
|
||||
# Execute the query to verify it has correct column structure
|
||||
result = await ds.get_internal_database().execute(sql, params)
|
||||
|
||||
# Should have 3 columns: parent, child, reason
|
||||
# This assertion would fail if the empty result only had 2 columns
|
||||
assert (
|
||||
len(result.columns) == 3
|
||||
), f"Expected 3 columns, got {len(result.columns)}: {result.columns}"
|
||||
assert result.columns == ["parent", "child", "reason"]
|
||||
|
||||
# Should have no rows (no rules = no access)
|
||||
assert len(result.rows) == 0
|
||||
|
||||
finally:
|
||||
# Restore original hook
|
||||
pm.hook.permission_resources_sql = original_hook
|
||||
|
|
@ -18,6 +18,7 @@ def ds_write(tmp_path_factory):
|
|||
"create table docs (id integer primary key, title text, score float, age integer)"
|
||||
)
|
||||
ds = Datasette([db_path], immutables=[db_path_immutable])
|
||||
ds.root_enabled = True
|
||||
yield ds
|
||||
db.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ from .utils import cookie_was_deleted, last_event
|
|||
from click.testing import CliRunner
|
||||
from datasette.utils import baseconv
|
||||
from datasette.cli import cli
|
||||
from datasette.resources import (
|
||||
InstanceResource,
|
||||
DatabaseResource,
|
||||
TableResource,
|
||||
)
|
||||
import pytest
|
||||
import time
|
||||
|
||||
|
|
@ -337,3 +342,165 @@ def test_cli_create_token(app_client, expires):
|
|||
else:
|
||||
expected_actor = None
|
||||
assert response.json == {"actor": expected_actor}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_with_root_enabled_gets_all_permissions(ds_client):
|
||||
"""Root user with root_enabled=True gets all permissions"""
|
||||
# Ensure catalog tables are populated
|
||||
await ds_client.ds.invoke_startup()
|
||||
await ds_client.ds._refresh_schemas()
|
||||
|
||||
# Set root_enabled to simulate --root flag
|
||||
ds_client.ds.root_enabled = True
|
||||
|
||||
root_actor = {"id": "root"}
|
||||
|
||||
# Test instance-level permissions (no resource)
|
||||
assert (
|
||||
await ds_client.ds.permission_allowed(root_actor, "permissions-debug", None)
|
||||
is True
|
||||
)
|
||||
assert await ds_client.ds.permission_allowed(root_actor, "debug-menu", None) is True
|
||||
|
||||
# Test view permissions using the new ds.allowed() method
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="view-instance", resource=InstanceResource(), actor=root_actor
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="view-database",
|
||||
resource=DatabaseResource("fixtures"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="view-table",
|
||||
resource=TableResource("fixtures", "facetable"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# Test write permissions using ds.allowed()
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="insert-row",
|
||||
resource=TableResource("fixtures", "facetable"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="delete-row",
|
||||
resource=TableResource("fixtures", "facetable"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="update-row",
|
||||
resource=TableResource("fixtures", "facetable"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="create-table",
|
||||
resource=DatabaseResource("fixtures"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="alter-table",
|
||||
resource=TableResource("fixtures", "facetable"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="drop-table",
|
||||
resource=TableResource("fixtures", "facetable"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_without_root_enabled_no_special_permissions(ds_client):
|
||||
"""Root user without root_enabled doesn't get automatic permissions"""
|
||||
# Ensure catalog tables are populated
|
||||
await ds_client.ds.invoke_startup()
|
||||
await ds_client.ds._refresh_schemas()
|
||||
|
||||
# Ensure root_enabled is NOT set (or is False)
|
||||
ds_client.ds.root_enabled = False
|
||||
|
||||
root_actor = {"id": "root"}
|
||||
|
||||
# Test permissions that normally require special access
|
||||
# Without root_enabled, root should follow normal permission rules
|
||||
|
||||
# View permissions should still work (default=True)
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="view-instance", resource=InstanceResource(), actor=root_actor
|
||||
)
|
||||
is True
|
||||
) # Default permission
|
||||
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="view-database",
|
||||
resource=DatabaseResource("fixtures"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is True
|
||||
) # Default permission
|
||||
|
||||
# But restricted permissions should NOT automatically be granted
|
||||
# Test with instance-level permission (no resource class)
|
||||
result = await ds_client.ds.permission_allowed(
|
||||
root_actor, "permissions-debug", None
|
||||
)
|
||||
assert (
|
||||
result is not True
|
||||
), "Root without root_enabled should not automatically get permissions-debug"
|
||||
|
||||
# Test with resource-based permissions using ds.allowed()
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="create-table",
|
||||
resource=DatabaseResource("fixtures"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is not True
|
||||
), "Root without root_enabled should not automatically get create-table"
|
||||
|
||||
assert (
|
||||
await ds_client.ds.allowed(
|
||||
action="drop-table",
|
||||
resource=TableResource("fixtures", "facetable"),
|
||||
actor=root_actor,
|
||||
)
|
||||
is not True
|
||||
), "Root without root_enabled should not automatically get drop-table"
|
||||
|
|
|
|||
|
|
@ -307,7 +307,57 @@ def test_setting_type_validation():
|
|||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--setting", "default_page_size", "dog"])
|
||||
assert result.exit_code == 2
|
||||
assert '"settings.default_page_size" should be an integer' in result.stderr
|
||||
assert '"settings.default_page_size" should be an integer' in result.output
|
||||
|
||||
|
||||
def test_setting_boolean_validation_invalid():
|
||||
"""Test that invalid boolean values are rejected"""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["--setting", "default_allow_sql", "invalid", "--get", "/-/settings.json"]
|
||||
)
|
||||
assert result.exit_code == 2
|
||||
assert (
|
||||
'"settings.default_allow_sql" should be on/off/true/false/1/0' in result.output
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("off", "false", "0"))
|
||||
def test_setting_boolean_validation_false_values(value):
|
||||
"""Test that 'off', 'false', '0' work for boolean settings"""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--setting",
|
||||
"default_allow_sql",
|
||||
value,
|
||||
"--get",
|
||||
"/_memory/-/query.json?sql=select+1",
|
||||
],
|
||||
)
|
||||
# Should be forbidden (setting is false)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Forbidden" in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("on", "true", "1"))
|
||||
def test_setting_boolean_validation_true_values(value):
|
||||
"""Test that 'on', 'true', '1' work for boolean settings"""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--setting",
|
||||
"default_allow_sql",
|
||||
value,
|
||||
"--get",
|
||||
"/_memory/-/query.json?sql=select+1&_shape=objects",
|
||||
],
|
||||
)
|
||||
# Should succeed (setting is true)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.output)["rows"][0] == {"1": 1}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("default_allow_sql", (True, False))
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ def test_invalid_settings(config_dir):
|
|||
try:
|
||||
with pytest.raises(StartupError) as ex:
|
||||
ds = Datasette([], config_dir=config_dir)
|
||||
assert ex.value.args[0] == "Invalid setting 'invalid' in datasette.json"
|
||||
assert ex.value.args[0] == "Invalid setting 'invalid' in config file"
|
||||
finally:
|
||||
(config_dir / "datasette.json").write_text(previous, "utf-8")
|
||||
|
||||
|
|
|
|||
|
|
@ -962,6 +962,9 @@ def test_edit_sql_link_not_shown_if_user_lacks_permission(permission_allowed):
|
|||
async def test_navigation_menu_links(
|
||||
ds_client, actor_id, should_have_links, should_not_have_links
|
||||
):
|
||||
# Enable root user if testing with root actor
|
||||
if actor_id == "root":
|
||||
ds_client.ds.root_enabled = True
|
||||
cookies = {}
|
||||
if actor_id:
|
||||
cookies = {"ds_actor": ds_client.actor_cookie({"id": actor_id})}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ async def ds_with_permissions():
|
|||
}
|
||||
}
|
||||
)
|
||||
ds.root_enabled = True
|
||||
await ds.invoke_startup()
|
||||
# Add some test databases
|
||||
ds.add_memory_database("content")
|
||||
|
|
@ -493,3 +494,84 @@ async def test_html_endpoints_return_html(ds_with_permissions, path, needs_debug
|
|||
# Check for HTML structure
|
||||
text = response.text
|
||||
assert "<!DOCTYPE html>" in text or "<html" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_user_respects_settings_deny():
|
||||
"""
|
||||
Test for issue #2509: Settings-based deny rules should override root user privileges.
|
||||
|
||||
When a database has `allow: false` in settings, the root user should NOT see
|
||||
that database in /-/allowed.json?action=view-database, even though root normally
|
||||
has all permissions.
|
||||
"""
|
||||
ds = Datasette(
|
||||
config={
|
||||
"databases": {
|
||||
"content": {
|
||||
"allow": False, # Deny everyone, including root
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
ds.root_enabled = True
|
||||
await ds.invoke_startup()
|
||||
ds.add_memory_database("content")
|
||||
|
||||
# Root user should NOT see the content database because settings deny it
|
||||
response = await ds.client.get(
|
||||
"/-/allowed.json?action=view-database",
|
||||
cookies={"ds_actor": ds.client.actor_cookie({"id": "root"})},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that content database is NOT in the allowed list
|
||||
allowed_databases = [item["parent"] for item in data["items"]]
|
||||
assert "content" not in allowed_databases, (
|
||||
f"Root user should not see 'content' database when settings deny it, "
|
||||
f"but found it in: {allowed_databases}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_user_respects_settings_deny_tables():
|
||||
"""
|
||||
Test for issue #2509: Settings-based deny rules should override root for tables too.
|
||||
|
||||
When a database has `allow: false` in settings, the root user should NOT see
|
||||
tables from that database in /-/allowed.json?action=view-table.
|
||||
"""
|
||||
ds = Datasette(
|
||||
config={
|
||||
"databases": {
|
||||
"content": {
|
||||
"allow": False, # Deny everyone, including root
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
ds.root_enabled = True
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Add a database with a table
|
||||
db = ds.add_memory_database("content")
|
||||
await db.execute_write("CREATE TABLE repos (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
await ds.refresh_schemas()
|
||||
|
||||
# Root user should NOT see tables from the content database
|
||||
response = await ds.client.get(
|
||||
"/-/allowed.json?action=view-table",
|
||||
cookies={"ds_actor": ds.client.actor_cookie({"id": "root"})},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that content.repos table is NOT in the allowed list
|
||||
content_tables = [
|
||||
item["child"] for item in data["items"] if item["parent"] == "content"
|
||||
]
|
||||
assert "repos" not in content_tables, (
|
||||
f"Root user should not see tables from 'content' database when settings deny it, "
|
||||
f"but found: {content_tables}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -375,7 +375,8 @@ def test_permissions_checked(app_client, path, permissions):
|
|||
async def test_permissions_debug(ds_client, filter_):
|
||||
ds_client.ds._permission_checks.clear()
|
||||
assert (await ds_client.get("/-/permissions")).status_code == 403
|
||||
# With the cookie it should work
|
||||
# With the cookie it should work (need to set root_enabled for root user)
|
||||
ds_client.ds.root_enabled = True
|
||||
cookie = ds_client.actor_cookie({"id": "root"})
|
||||
response = await ds_client.get(
|
||||
f"/-/permissions?filter={filter_}", cookies={"ds_actor": cookie}
|
||||
|
|
@ -418,8 +419,8 @@ async def test_permissions_debug(ds_client, filter_):
|
|||
},
|
||||
{
|
||||
"action": "view-instance",
|
||||
"result": None,
|
||||
"used_default": True,
|
||||
"result": True,
|
||||
"used_default": False,
|
||||
"actor": {"id": "root"},
|
||||
},
|
||||
{"action": "debug-menu", "result": False, "used_default": True, "actor": None},
|
||||
|
|
@ -691,6 +692,7 @@ async def test_actor_restricted_permissions(
|
|||
perms_ds, actor, permission, resource_1, resource_2, expected_result
|
||||
):
|
||||
perms_ds.pdb = True
|
||||
perms_ds.root_enabled = True # Allow root actor to access /-/permissions
|
||||
cookies = {"ds_actor": perms_ds.sign({"a": {"id": "root"}}, "actor")}
|
||||
csrftoken = (await perms_ds.client.get("/-/permissions", cookies=cookies)).cookies[
|
||||
"ds_csrftoken"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from datasette.app import Datasette
|
|||
from datasette import cli, hookimpl, Permission
|
||||
from datasette.filters import FilterArguments
|
||||
from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm
|
||||
from datasette.utils.permissions import PluginSQL
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.utils.sqlite import sqlite3
|
||||
from datasette.utils import StartupError, await_me_maybe
|
||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
|
|
@ -722,7 +722,7 @@ async def test_hook_permission_resources_sql():
|
|||
collected.append(block)
|
||||
|
||||
assert collected
|
||||
assert all(isinstance(item, PluginSQL) for item in collected)
|
||||
assert all(isinstance(item, PermissionSQL) for item in collected)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1560,6 +1560,17 @@ async def test_hook_register_events():
|
|||
assert any(k.__name__ == "OneEvent" for k in datasette.event_classes)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_register_actions():
|
||||
datasette = Datasette(memory=True, plugins_dir=PLUGINS_DIR)
|
||||
await datasette.invoke_startup()
|
||||
# Check that the custom action from my_plugin.py is registered
|
||||
assert "view-collection" in datasette.actions
|
||||
action = datasette.actions["view-collection"]
|
||||
assert action.abbr == "vc"
|
||||
assert action.description == "View a collection"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="TODO")
|
||||
@pytest.mark.parametrize(
|
||||
"metadata,config,expected_metadata,expected_config",
|
||||
|
|
|
|||
548
tests/test_tables_endpoint.py
Normal file
548
tests/test_tables_endpoint.py
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
"""
|
||||
Tests for the /-/tables endpoint.
|
||||
|
||||
These tests verify that the new TablesView correctly uses the allowed_resources() API.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from datasette.app import Datasette
|
||||
from datasette.plugins import pm
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette import hookimpl
|
||||
|
||||
|
||||
# Test plugin that provides permission rules
|
||||
class PermissionRulesPlugin:
|
||||
def __init__(self, rules_callback):
|
||||
self.rules_callback = rules_callback
|
||||
|
||||
@hookimpl
|
||||
def permission_resources_sql(self, datasette, actor, action):
|
||||
return self.rules_callback(datasette, actor, action)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_ds():
|
||||
"""Create a test Datasette instance with sample data (fresh for each test)"""
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Add test databases with some tables
|
||||
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 db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS sensitive (id INTEGER PRIMARY KEY)"
|
||||
)
|
||||
|
||||
db2 = ds.add_memory_database("production")
|
||||
await db2.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY)"
|
||||
)
|
||||
await db2.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY)"
|
||||
)
|
||||
|
||||
# Refresh schemas to populate catalog_tables in internal database
|
||||
await ds._refresh_schemas()
|
||||
|
||||
return ds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_global_access(test_ds):
|
||||
"""Test /-/tables with global access permissions"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("id") == "alice":
|
||||
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 None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
# Use the allowed_resources API directly
|
||||
tables = await test_ds.allowed_resources("view-table", {"id": "alice"})
|
||||
|
||||
# Convert to the format the endpoint returns
|
||||
result = [
|
||||
{
|
||||
"name": f"{t.parent}/{t.child}",
|
||||
"url": test_ds.urls.table(t.parent, t.child),
|
||||
}
|
||||
for t in tables
|
||||
]
|
||||
|
||||
# Alice should see all tables
|
||||
assert len(result) == 5
|
||||
table_names = {m["name"] for m in result}
|
||||
assert "analytics/events" in table_names
|
||||
assert "analytics/users" in table_names
|
||||
assert "analytics/sensitive" in table_names
|
||||
assert "production/customers" in table_names
|
||||
assert "production/orders" in table_names
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_database_restriction(test_ds):
|
||||
"""Test /-/tables with database-level restriction"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("role") == "analyst":
|
||||
# Allow only analytics database
|
||||
sql = "SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analyst access' AS reason"
|
||||
return PermissionSQL(source="test", sql=sql, params={})
|
||||
return None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
tables = await test_ds.allowed_resources(
|
||||
"view-table", {"id": "bob", "role": "analyst"}
|
||||
)
|
||||
result = [
|
||||
{
|
||||
"name": f"{t.parent}/{t.child}",
|
||||
"url": test_ds.urls.table(t.parent, t.child),
|
||||
}
|
||||
for t in tables
|
||||
]
|
||||
|
||||
# Bob should only see analytics tables
|
||||
analytics_tables = [m for m in result if m["name"].startswith("analytics/")]
|
||||
production_tables = [m for m in result if m["name"].startswith("production/")]
|
||||
|
||||
assert len(analytics_tables) == 3
|
||||
table_names = {m["name"] for m in analytics_tables}
|
||||
assert "analytics/events" in table_names
|
||||
assert "analytics/users" in table_names
|
||||
assert "analytics/sensitive" in table_names
|
||||
|
||||
# Should not see production tables (unless default_permissions allows them)
|
||||
# Note: default_permissions.py provides default allows, so we just check analytics are present
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_table_exception(test_ds):
|
||||
"""Test /-/tables with table-level exception (deny database, allow specific table)"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("id") == "carol":
|
||||
# Deny analytics database, but allow analytics.users specifically
|
||||
sql = """
|
||||
SELECT 'analytics' AS parent, NULL AS child, 0 AS allow, 'deny analytics' AS reason
|
||||
UNION ALL
|
||||
SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow, 'carol exception' AS reason
|
||||
"""
|
||||
return PermissionSQL(source="test", sql=sql, params={})
|
||||
return None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
tables = await test_ds.allowed_resources("view-table", {"id": "carol"})
|
||||
result = [
|
||||
{
|
||||
"name": f"{t.parent}/{t.child}",
|
||||
"url": test_ds.urls.table(t.parent, t.child),
|
||||
}
|
||||
for t in tables
|
||||
]
|
||||
|
||||
# Carol should see analytics.users but not other analytics tables
|
||||
analytics_tables = [m for m in result if m["name"].startswith("analytics/")]
|
||||
assert len(analytics_tables) == 1
|
||||
table_names = {m["name"] for m in analytics_tables}
|
||||
assert "analytics/users" in table_names
|
||||
|
||||
# Should NOT see analytics.events or analytics.sensitive
|
||||
assert "analytics/events" not in table_names
|
||||
assert "analytics/sensitive" not in table_names
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_deny_overrides_allow(test_ds):
|
||||
"""Test that child-level DENY beats parent-level ALLOW"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("role") == "analyst":
|
||||
# Allow analytics, but deny sensitive table
|
||||
sql = """
|
||||
SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'allow analytics' AS reason
|
||||
UNION ALL
|
||||
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, 'deny sensitive' AS reason
|
||||
"""
|
||||
return PermissionSQL(source="test", sql=sql, params={})
|
||||
return None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
tables = await test_ds.allowed_resources(
|
||||
"view-table", {"id": "bob", "role": "analyst"}
|
||||
)
|
||||
result = [
|
||||
{
|
||||
"name": f"{t.parent}/{t.child}",
|
||||
"url": test_ds.urls.table(t.parent, t.child),
|
||||
}
|
||||
for t in tables
|
||||
]
|
||||
|
||||
analytics_tables = [m for m in result if m["name"].startswith("analytics/")]
|
||||
|
||||
# Should see users and events but NOT sensitive
|
||||
table_names = {m["name"] for m in analytics_tables}
|
||||
assert "analytics/users" in table_names
|
||||
assert "analytics/events" in table_names
|
||||
assert "analytics/sensitive" not in table_names
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_no_permissions():
|
||||
"""Test /-/tables when user has no custom permissions (only defaults)"""
|
||||
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Add a single database
|
||||
db = ds.add_memory_database("testdb")
|
||||
await db.execute_write("CREATE TABLE items (id INTEGER PRIMARY KEY)")
|
||||
await ds._refresh_schemas()
|
||||
|
||||
# Unknown actor with no custom permissions
|
||||
tables = await ds.allowed_resources("view-table", {"id": "unknown"})
|
||||
result = [
|
||||
{"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)}
|
||||
for t in tables
|
||||
]
|
||||
|
||||
# Should see tables (due to default_permissions.py providing default allow)
|
||||
assert len(result) >= 1
|
||||
assert any(m["name"].endswith("/items") for m in result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_specific_table_only(test_ds):
|
||||
"""Test /-/tables when only specific tables are allowed (no parent/global rules)"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("id") == "dave":
|
||||
# Allow only specific tables, no parent-level or global rules
|
||||
sql = """
|
||||
SELECT 'analytics' AS parent, 'users' AS child, 1 AS allow, 'specific table 1' AS reason
|
||||
UNION ALL
|
||||
SELECT 'production' AS parent, 'orders' AS child, 1 AS allow, 'specific table 2' AS reason
|
||||
"""
|
||||
return PermissionSQL(source="test", sql=sql, params={})
|
||||
return None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
tables = await test_ds.allowed_resources("view-table", {"id": "dave"})
|
||||
result = [
|
||||
{
|
||||
"name": f"{t.parent}/{t.child}",
|
||||
"url": test_ds.urls.table(t.parent, t.child),
|
||||
}
|
||||
for t in tables
|
||||
]
|
||||
|
||||
# Should see only the two specifically allowed tables
|
||||
specific_tables = [
|
||||
m for m in result if m["name"] in ("analytics/users", "production/orders")
|
||||
]
|
||||
|
||||
assert len(specific_tables) == 2
|
||||
table_names = {m["name"] for m in specific_tables}
|
||||
assert "analytics/users" in table_names
|
||||
assert "production/orders" in table_names
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_empty_result(test_ds):
|
||||
"""Test /-/tables when all tables are explicitly denied"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("id") == "blocked":
|
||||
# Global deny
|
||||
sql = "SELECT NULL AS parent, NULL AS child, 0 AS allow, 'global deny' AS reason"
|
||||
return PermissionSQL(source="test", sql=sql, params={})
|
||||
return None
|
||||
|
||||
plugin = PermissionRulesPlugin(rules_callback)
|
||||
pm.register(plugin, name="test_plugin")
|
||||
|
||||
try:
|
||||
tables = await test_ds.allowed_resources("view-table", {"id": "blocked"})
|
||||
result = [
|
||||
{
|
||||
"name": f"{t.parent}/{t.child}",
|
||||
"url": test_ds.urls.table(t.parent, t.child),
|
||||
}
|
||||
for t in tables
|
||||
]
|
||||
|
||||
# Global deny should block access to all tables
|
||||
assert len(result) == 0
|
||||
|
||||
finally:
|
||||
pm.unregister(plugin, name="test_plugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_search_single_term():
|
||||
"""Test /-/tables?q=user to filter tables matching 'user'"""
|
||||
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Add database with various table names
|
||||
db = ds.add_memory_database("search_test")
|
||||
await db.execute_write("CREATE TABLE users (id INTEGER)")
|
||||
await db.execute_write("CREATE TABLE user_profiles (id INTEGER)")
|
||||
await db.execute_write("CREATE TABLE events (id INTEGER)")
|
||||
await db.execute_write("CREATE TABLE posts (id INTEGER)")
|
||||
await ds._refresh_schemas()
|
||||
|
||||
# Get all tables in the new format
|
||||
all_tables = await ds.allowed_resources("view-table", None)
|
||||
matches = [
|
||||
{"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)}
|
||||
for t in all_tables
|
||||
]
|
||||
|
||||
# Filter for "user" (extract table name from "db/table")
|
||||
import re
|
||||
|
||||
pattern = ".*user.*"
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
filtered = [m for m in matches if regex.match(m["name"].split("/", 1)[1])]
|
||||
|
||||
# Should match users and user_profiles but not events or posts
|
||||
table_names = {m["name"].split("/", 1)[1] for m in filtered}
|
||||
assert "users" in table_names
|
||||
assert "user_profiles" in table_names
|
||||
assert "events" not in table_names
|
||||
assert "posts" not in table_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_search_multiple_terms():
|
||||
"""Test /-/tables?q=user+profile to filter tables matching .*user.*profile.*"""
|
||||
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Add database with various table names
|
||||
db = ds.add_memory_database("search_test2")
|
||||
await db.execute_write("CREATE TABLE user_profiles (id INTEGER)")
|
||||
await db.execute_write("CREATE TABLE users (id INTEGER)")
|
||||
await db.execute_write("CREATE TABLE profile_settings (id INTEGER)")
|
||||
await db.execute_write("CREATE TABLE events (id INTEGER)")
|
||||
await ds._refresh_schemas()
|
||||
|
||||
# Get all tables in the new format
|
||||
all_tables = await ds.allowed_resources("view-table", None)
|
||||
matches = [
|
||||
{"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)}
|
||||
for t in all_tables
|
||||
]
|
||||
|
||||
# Filter for "user profile" (two terms, extract table name from "db/table")
|
||||
import re
|
||||
|
||||
terms = ["user", "profile"]
|
||||
pattern = ".*" + ".*".join(re.escape(term) for term in terms) + ".*"
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
filtered = [m for m in matches if regex.match(m["name"].split("/", 1)[1])]
|
||||
|
||||
# Should match only user_profiles (has both user and profile in that order)
|
||||
table_names = {m["name"].split("/", 1)[1] for m in filtered}
|
||||
assert "user_profiles" in table_names
|
||||
assert "users" not in table_names # doesn't have "profile"
|
||||
assert "profile_settings" not in table_names # doesn't have "user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_search_ordering():
|
||||
"""Test that search results are ordered by shortest name first"""
|
||||
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Add database with tables of various lengths containing "user"
|
||||
db = ds.add_memory_database("order_test")
|
||||
await db.execute_write("CREATE TABLE users (id INTEGER)")
|
||||
await db.execute_write("CREATE TABLE user_profiles (id INTEGER)")
|
||||
await db.execute_write(
|
||||
"CREATE TABLE u (id INTEGER)"
|
||||
) # Shortest, but doesn't match "user"
|
||||
await db.execute_write(
|
||||
"CREATE TABLE user_authentication_tokens (id INTEGER)"
|
||||
) # Longest
|
||||
await db.execute_write("CREATE TABLE user_data (id INTEGER)")
|
||||
await ds._refresh_schemas()
|
||||
|
||||
# Get all tables in the new format
|
||||
all_tables = await ds.allowed_resources("view-table", None)
|
||||
matches = [
|
||||
{"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)}
|
||||
for t in all_tables
|
||||
]
|
||||
|
||||
# Filter for "user" and sort by table name length
|
||||
import re
|
||||
|
||||
pattern = ".*user.*"
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
filtered = [m for m in matches if regex.match(m["name"].split("/", 1)[1])]
|
||||
filtered.sort(key=lambda m: len(m["name"].split("/", 1)[1]))
|
||||
|
||||
# Should be ordered: users, user_data, user_profiles, user_authentication_tokens
|
||||
matching_names = [m["name"].split("/", 1)[1] for m in filtered]
|
||||
assert matching_names[0] == "users" # shortest
|
||||
assert len(matching_names[0]) < len(matching_names[1])
|
||||
assert len(matching_names[-1]) > len(matching_names[-2])
|
||||
assert matching_names[-1] == "user_authentication_tokens" # longest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_search_case_insensitive():
|
||||
"""Test that search is case-insensitive"""
|
||||
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Add database with mixed case table names
|
||||
db = ds.add_memory_database("case_test")
|
||||
await db.execute_write("CREATE TABLE Users (id INTEGER)")
|
||||
await db.execute_write("CREATE TABLE USER_PROFILES (id INTEGER)")
|
||||
await db.execute_write("CREATE TABLE user_data (id INTEGER)")
|
||||
await ds._refresh_schemas()
|
||||
|
||||
# Get all tables in the new format
|
||||
all_tables = await ds.allowed_resources("view-table", None)
|
||||
matches = [
|
||||
{"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)}
|
||||
for t in all_tables
|
||||
]
|
||||
|
||||
# Filter for "user" (lowercase) should match all case variants
|
||||
import re
|
||||
|
||||
pattern = ".*user.*"
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
filtered = [m for m in matches if regex.match(m["name"].split("/", 1)[1])]
|
||||
|
||||
# Should match all three tables regardless of case
|
||||
table_names = {m["name"].split("/", 1)[1] for m in filtered}
|
||||
assert "Users" in table_names
|
||||
assert "USER_PROFILES" in table_names
|
||||
assert "user_data" in table_names
|
||||
assert len(filtered) >= 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_search_no_matches():
|
||||
"""Test search with no matching tables returns empty list"""
|
||||
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Add database with tables that won't match search
|
||||
db = ds.add_memory_database("nomatch_test")
|
||||
await db.execute_write("CREATE TABLE events (id INTEGER)")
|
||||
await db.execute_write("CREATE TABLE posts (id INTEGER)")
|
||||
await ds._refresh_schemas()
|
||||
|
||||
# Get all tables in the new format
|
||||
all_tables = await ds.allowed_resources("view-table", None)
|
||||
matches = [
|
||||
{"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)}
|
||||
for t in all_tables
|
||||
]
|
||||
|
||||
# Filter for "zzz" which doesn't exist
|
||||
import re
|
||||
|
||||
pattern = ".*zzz.*"
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
filtered = [m for m in matches if regex.match(m["name"].split("/", 1)[1])]
|
||||
|
||||
# Should return empty list
|
||||
assert len(filtered) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_config_database_allow():
|
||||
"""Test that database-level allow blocks work for view-table action"""
|
||||
|
||||
# Simulate: -s databases.restricted_db.allow.id root
|
||||
config = {"databases": {"restricted_db": {"allow": {"id": "root"}}}}
|
||||
|
||||
ds = Datasette(config=config)
|
||||
await ds.invoke_startup()
|
||||
|
||||
# Create databases
|
||||
restricted_db = ds.add_memory_database("restricted_db")
|
||||
await restricted_db.execute_write("CREATE TABLE users (id INTEGER)")
|
||||
await restricted_db.execute_write("CREATE TABLE posts (id INTEGER)")
|
||||
|
||||
public_db = ds.add_memory_database("public_db")
|
||||
await public_db.execute_write("CREATE TABLE articles (id INTEGER)")
|
||||
|
||||
await ds._refresh_schemas()
|
||||
|
||||
# Root user should see restricted_db tables
|
||||
root_tables = await ds.allowed_resources("view-table", {"id": "root"})
|
||||
root_list = [
|
||||
{"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)}
|
||||
for t in root_tables
|
||||
]
|
||||
restricted_tables_root = [
|
||||
m for m in root_list if m["name"].startswith("restricted_db/")
|
||||
]
|
||||
assert len(restricted_tables_root) == 2
|
||||
table_names = {m["name"] for m in restricted_tables_root}
|
||||
assert "restricted_db/users" in table_names
|
||||
assert "restricted_db/posts" in table_names
|
||||
|
||||
# Alice should NOT see restricted_db tables
|
||||
alice_tables = await ds.allowed_resources("view-table", {"id": "alice"})
|
||||
alice_list = [
|
||||
{"name": f"{t.parent}/{t.child}", "url": ds.urls.table(t.parent, t.child)}
|
||||
for t in alice_tables
|
||||
]
|
||||
restricted_tables_alice = [
|
||||
m for m in alice_list if m["name"].startswith("restricted_db/")
|
||||
]
|
||||
assert len(restricted_tables_alice) == 0
|
||||
|
||||
# But Alice should see public_db tables (no restrictions)
|
||||
public_tables_alice = [m for m in alice_list if m["name"].startswith("public_db/")]
|
||||
assert len(public_tables_alice) == 1
|
||||
assert "public_db/articles" in {m["name"] for m in public_tables_alice}
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
import pytest
|
||||
from datasette.app import Datasette
|
||||
from datasette.utils.permissions import (
|
||||
PluginSQL,
|
||||
PluginProvider,
|
||||
resolve_permissions_from_catalog,
|
||||
)
|
||||
from typing import List
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.utils.permissions import resolve_permissions_from_catalog
|
||||
from typing import Callable, List
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -25,14 +22,14 @@ NO_RULES_SQL = (
|
|||
)
|
||||
|
||||
|
||||
def plugin_allow_all_for_user(user: str) -> PluginProvider:
|
||||
def provider(action: str) -> PluginSQL:
|
||||
return PluginSQL(
|
||||
def plugin_allow_all_for_user(user: str) -> Callable[[str], PermissionSQL]:
|
||||
def provider(action: str) -> PermissionSQL:
|
||||
return PermissionSQL(
|
||||
"allow_all",
|
||||
"""
|
||||
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
||||
'global allow for ' || :user || ' on ' || :action AS reason
|
||||
WHERE :actor = :user
|
||||
WHERE :actor_id = :user
|
||||
""",
|
||||
{"user": user, "action": action},
|
||||
)
|
||||
|
|
@ -40,14 +37,16 @@ def plugin_allow_all_for_user(user: str) -> PluginProvider:
|
|||
return provider
|
||||
|
||||
|
||||
def plugin_deny_specific_table(user: str, parent: str, child: str) -> PluginProvider:
|
||||
def provider(action: str) -> PluginSQL:
|
||||
return PluginSQL(
|
||||
def plugin_deny_specific_table(
|
||||
user: str, parent: str, child: str
|
||||
) -> Callable[[str], PermissionSQL]:
|
||||
def provider(action: str) -> PermissionSQL:
|
||||
return PermissionSQL(
|
||||
"deny_specific_table",
|
||||
"""
|
||||
SELECT :parent AS parent, :child AS child, 0 AS allow,
|
||||
'deny ' || :parent || '/' || :child || ' for ' || :user || ' on ' || :action AS reason
|
||||
WHERE :actor = :user
|
||||
WHERE :actor_id = :user
|
||||
""",
|
||||
{"parent": parent, "child": child, "user": user, "action": action},
|
||||
)
|
||||
|
|
@ -55,9 +54,9 @@ def plugin_deny_specific_table(user: str, parent: str, child: str) -> PluginProv
|
|||
return provider
|
||||
|
||||
|
||||
def plugin_org_policy_deny_parent(parent: str) -> PluginProvider:
|
||||
def provider(action: str) -> PluginSQL:
|
||||
return PluginSQL(
|
||||
def plugin_org_policy_deny_parent(parent: str) -> Callable[[str], PermissionSQL]:
|
||||
def provider(action: str) -> PermissionSQL:
|
||||
return PermissionSQL(
|
||||
"org_policy_parent_deny",
|
||||
"""
|
||||
SELECT :parent AS parent, NULL AS child, 0 AS allow,
|
||||
|
|
@ -69,14 +68,16 @@ def plugin_org_policy_deny_parent(parent: str) -> PluginProvider:
|
|||
return provider
|
||||
|
||||
|
||||
def plugin_allow_parent_for_user(user: str, parent: str) -> PluginProvider:
|
||||
def provider(action: str) -> PluginSQL:
|
||||
return PluginSQL(
|
||||
def plugin_allow_parent_for_user(
|
||||
user: str, parent: str
|
||||
) -> Callable[[str], PermissionSQL]:
|
||||
def provider(action: str) -> PermissionSQL:
|
||||
return PermissionSQL(
|
||||
"allow_parent",
|
||||
"""
|
||||
SELECT :parent AS parent, NULL AS child, 1 AS allow,
|
||||
'allow full parent for ' || :user || ' on ' || :action AS reason
|
||||
WHERE :actor = :user
|
||||
WHERE :actor_id = :user
|
||||
""",
|
||||
{"parent": parent, "user": user, "action": action},
|
||||
)
|
||||
|
|
@ -84,14 +85,16 @@ def plugin_allow_parent_for_user(user: str, parent: str) -> PluginProvider:
|
|||
return provider
|
||||
|
||||
|
||||
def plugin_child_allow_for_user(user: str, parent: str, child: str) -> PluginProvider:
|
||||
def provider(action: str) -> PluginSQL:
|
||||
return PluginSQL(
|
||||
def plugin_child_allow_for_user(
|
||||
user: str, parent: str, child: str
|
||||
) -> Callable[[str], PermissionSQL]:
|
||||
def provider(action: str) -> PermissionSQL:
|
||||
return PermissionSQL(
|
||||
"allow_child",
|
||||
"""
|
||||
SELECT :parent AS parent, :child AS child, 1 AS allow,
|
||||
'allow child for ' || :user || ' on ' || :action AS reason
|
||||
WHERE :actor = :user
|
||||
WHERE :actor_id = :user
|
||||
""",
|
||||
{"parent": parent, "child": child, "user": user, "action": action},
|
||||
)
|
||||
|
|
@ -99,9 +102,9 @@ def plugin_child_allow_for_user(user: str, parent: str, child: str) -> PluginPro
|
|||
return provider
|
||||
|
||||
|
||||
def plugin_root_deny_for_all() -> PluginProvider:
|
||||
def provider(action: str) -> PluginSQL:
|
||||
return PluginSQL(
|
||||
def plugin_root_deny_for_all() -> Callable[[str], PermissionSQL]:
|
||||
def provider(action: str) -> PermissionSQL:
|
||||
return PermissionSQL(
|
||||
"root_deny",
|
||||
"""
|
||||
SELECT NULL AS parent, NULL AS child, 0 AS allow, 'root deny for all on ' || :action AS reason
|
||||
|
|
@ -114,25 +117,25 @@ def plugin_root_deny_for_all() -> PluginProvider:
|
|||
|
||||
def plugin_conflicting_same_child_rules(
|
||||
user: str, parent: str, child: str
|
||||
) -> List[PluginProvider]:
|
||||
def allow_provider(action: str) -> PluginSQL:
|
||||
return PluginSQL(
|
||||
) -> List[Callable[[str], PermissionSQL]]:
|
||||
def allow_provider(action: str) -> PermissionSQL:
|
||||
return PermissionSQL(
|
||||
"conflict_child_allow",
|
||||
"""
|
||||
SELECT :parent AS parent, :child AS child, 1 AS allow,
|
||||
'team grant at child for ' || :user || ' on ' || :action AS reason
|
||||
WHERE :actor = :user
|
||||
WHERE :actor_id = :user
|
||||
""",
|
||||
{"parent": parent, "child": child, "user": user, "action": action},
|
||||
)
|
||||
|
||||
def deny_provider(action: str) -> PluginSQL:
|
||||
return PluginSQL(
|
||||
def deny_provider(action: str) -> PermissionSQL:
|
||||
return PermissionSQL(
|
||||
"conflict_child_deny",
|
||||
"""
|
||||
SELECT :parent AS parent, :child AS child, 0 AS allow,
|
||||
'exception deny at child for ' || :user || ' on ' || :action AS reason
|
||||
WHERE :actor = :user
|
||||
WHERE :actor_id = :user
|
||||
""",
|
||||
{"parent": parent, "child": child, "user": user, "action": action},
|
||||
)
|
||||
|
|
@ -140,20 +143,22 @@ def plugin_conflicting_same_child_rules(
|
|||
return [allow_provider, deny_provider]
|
||||
|
||||
|
||||
def plugin_allow_all_for_action(user: str, allowed_action: str) -> PluginProvider:
|
||||
def provider(action: str) -> PluginSQL:
|
||||
def plugin_allow_all_for_action(
|
||||
user: str, allowed_action: str
|
||||
) -> Callable[[str], PermissionSQL]:
|
||||
def provider(action: str) -> PermissionSQL:
|
||||
if action != allowed_action:
|
||||
return PluginSQL(
|
||||
return PermissionSQL(
|
||||
f"allow_all_{allowed_action}_noop",
|
||||
NO_RULES_SQL,
|
||||
{},
|
||||
)
|
||||
return PluginSQL(
|
||||
return PermissionSQL(
|
||||
f"allow_all_{allowed_action}",
|
||||
"""
|
||||
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
||||
'global allow for ' || :user || ' on ' || :action AS reason
|
||||
WHERE :actor = :user
|
||||
WHERE :actor_id = :user
|
||||
""",
|
||||
{"user": user, "action": action},
|
||||
)
|
||||
|
|
@ -247,7 +252,12 @@ async def test_alice_global_allow_with_specific_denies_catalog(db):
|
|||
plugin_org_policy_deny_parent("hr"),
|
||||
]
|
||||
rows = await resolve_permissions_from_catalog(
|
||||
db, "alice", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||
db,
|
||||
{"id": "alice"},
|
||||
plugins,
|
||||
VIEW_TABLE,
|
||||
TABLE_CANDIDATES_SQL,
|
||||
implicit_deny=True,
|
||||
)
|
||||
# Alice can see everything except accounting/sales and hr/*
|
||||
assert "/accounting/sales" in res_denied(rows)
|
||||
|
|
@ -269,7 +279,12 @@ async def test_carol_parent_allow_but_child_conflict_deny_wins_catalog(db):
|
|||
*plugin_conflicting_same_child_rules("carol", "analytics", "secret"),
|
||||
]
|
||||
rows = await resolve_permissions_from_catalog(
|
||||
db, "carol", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||
db,
|
||||
{"id": "carol"},
|
||||
plugins,
|
||||
VIEW_TABLE,
|
||||
TABLE_CANDIDATES_SQL,
|
||||
implicit_deny=True,
|
||||
)
|
||||
allowed_analytics = res_allowed(rows, parent="analytics")
|
||||
denied_analytics = res_denied(rows, parent="analytics")
|
||||
|
|
@ -290,7 +305,12 @@ async def test_specificity_child_allow_overrides_parent_deny_catalog(db):
|
|||
), # child allow beats parent deny
|
||||
]
|
||||
rows = await resolve_permissions_from_catalog(
|
||||
db, "alice", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||
db,
|
||||
{"id": "alice"},
|
||||
plugins,
|
||||
VIEW_TABLE,
|
||||
TABLE_CANDIDATES_SQL,
|
||||
implicit_deny=True,
|
||||
)
|
||||
|
||||
# table02 allowed, other analytics tables denied
|
||||
|
|
@ -311,7 +331,7 @@ async def test_root_deny_all_but_parent_allow_rescues_specific_parent_catalog(db
|
|||
), # parent allow (more specific)
|
||||
]
|
||||
rows = await resolve_permissions_from_catalog(
|
||||
db, "bob", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||
db, {"id": "bob"}, plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||
)
|
||||
for r in rows:
|
||||
if r["parent"] == "accounting":
|
||||
|
|
@ -328,7 +348,12 @@ async def test_parent_scoped_candidates(db):
|
|||
plugin_allow_parent_for_user("carol", "analytics"),
|
||||
]
|
||||
rows = await resolve_permissions_from_catalog(
|
||||
db, "carol", plugins, VIEW_TABLE, PARENT_CANDIDATES_SQL, implicit_deny=True
|
||||
db,
|
||||
{"id": "carol"},
|
||||
plugins,
|
||||
VIEW_TABLE,
|
||||
PARENT_CANDIDATES_SQL,
|
||||
implicit_deny=True,
|
||||
)
|
||||
d = {r["resource"]: r["allow"] for r in rows}
|
||||
assert d["/analytics"] == 1
|
||||
|
|
@ -342,13 +367,23 @@ async def test_implicit_deny_behavior(db):
|
|||
|
||||
# implicit_deny=True -> everything denied with reason 'implicit deny'
|
||||
rows = await resolve_permissions_from_catalog(
|
||||
db, "erin", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||
db,
|
||||
{"id": "erin"},
|
||||
plugins,
|
||||
VIEW_TABLE,
|
||||
TABLE_CANDIDATES_SQL,
|
||||
implicit_deny=True,
|
||||
)
|
||||
assert all(r["allow"] == 0 and r["reason"] == "implicit deny" for r in rows)
|
||||
|
||||
# implicit_deny=False -> no winner => allow is None, reason is None
|
||||
rows2 = await resolve_permissions_from_catalog(
|
||||
db, "erin", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=False
|
||||
db,
|
||||
{"id": "erin"},
|
||||
plugins,
|
||||
VIEW_TABLE,
|
||||
TABLE_CANDIDATES_SQL,
|
||||
implicit_deny=False,
|
||||
)
|
||||
assert all(r["allow"] is None and r["reason"] is None for r in rows2)
|
||||
|
||||
|
|
@ -384,7 +419,7 @@ async def test_candidate_filters_via_params(db):
|
|||
# Case 1: exclude memory dbs, require schema_version >= 2 -> only analytics appear, and thus are allowed
|
||||
rows = await resolve_permissions_from_catalog(
|
||||
db,
|
||||
"dev",
|
||||
{"id": "dev"},
|
||||
plugins,
|
||||
VIEW_TABLE,
|
||||
candidate_sql,
|
||||
|
|
@ -398,7 +433,7 @@ async def test_candidate_filters_via_params(db):
|
|||
# but root deny wins except where specifically allowed (none except analytics parent allow doesn’t apply to table depth if candidate includes children; still fine—policy is explicit).
|
||||
rows2 = await resolve_permissions_from_catalog(
|
||||
db,
|
||||
"dev",
|
||||
{"id": "dev"},
|
||||
plugins,
|
||||
VIEW_TABLE,
|
||||
candidate_sql,
|
||||
|
|
@ -418,7 +453,7 @@ async def test_action_specific_rules(db):
|
|||
|
||||
view_rows = await resolve_permissions_from_catalog(
|
||||
db,
|
||||
"dana",
|
||||
{"id": "dana"},
|
||||
plugins,
|
||||
VIEW_TABLE,
|
||||
TABLE_CANDIDATES_SQL,
|
||||
|
|
@ -429,7 +464,7 @@ async def test_action_specific_rules(db):
|
|||
|
||||
insert_rows = await resolve_permissions_from_catalog(
|
||||
db,
|
||||
"dana",
|
||||
{"id": "dana"},
|
||||
plugins,
|
||||
"insert-row",
|
||||
TABLE_CANDIDATES_SQL,
|
||||
|
|
@ -438,3 +473,49 @@ async def test_action_specific_rules(db):
|
|||
assert insert_rows and all(r["allow"] == 0 for r in insert_rows)
|
||||
assert all(r["reason"] == "implicit deny" for r in insert_rows)
|
||||
assert all(r["action"] == "insert-row" for r in insert_rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_actor_actor_id_action_parameters_available(db):
|
||||
"""Test that :actor (JSON), :actor_id, and :action are all available in SQL"""
|
||||
await seed_catalog(db)
|
||||
|
||||
def plugin_using_all_parameters() -> Callable[[str], PermissionSQL]:
|
||||
def provider(action: str) -> PermissionSQL:
|
||||
return PermissionSQL(
|
||||
"test_all_params",
|
||||
"""
|
||||
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
||||
'Actor ID: ' || COALESCE(:actor_id, 'null') ||
|
||||
', Actor JSON: ' || COALESCE(:actor, 'null') ||
|
||||
', Action: ' || :action AS reason
|
||||
WHERE :actor_id = 'test_user' AND :action = 'view-table'
|
||||
AND json_extract(:actor, '$.role') = 'admin'
|
||||
""",
|
||||
{},
|
||||
)
|
||||
|
||||
return provider
|
||||
|
||||
plugins = [plugin_using_all_parameters()]
|
||||
|
||||
# Test with full actor dict
|
||||
rows = await resolve_permissions_from_catalog(
|
||||
db,
|
||||
{"id": "test_user", "role": "admin"},
|
||||
plugins,
|
||||
"view-table",
|
||||
TABLE_CANDIDATES_SQL,
|
||||
implicit_deny=True,
|
||||
)
|
||||
|
||||
# Should have allowed rows with reason containing all the info
|
||||
allowed = [r for r in rows if r["allow"] == 1]
|
||||
assert len(allowed) > 0
|
||||
|
||||
# Check that the reason string contains evidence of all parameters
|
||||
reason = allowed[0]["reason"]
|
||||
assert "test_user" in reason
|
||||
assert "view-table" in reason
|
||||
# The :actor parameter should be the JSON string
|
||||
assert "Actor JSON:" in reason
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue