diff --git a/datasette/app.py b/datasette/app.py index 6c7026a8..79cbb8f0 100644 --- a/datasette/app.py +++ b/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(\.(?Pjson))?$", + ) add_route( LogoutView.as_view(self), r"/-/logout$", diff --git a/datasette/cli.py b/datasette/cli.py index bacabc4c..db489e7b 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -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 ) diff --git a/datasette/default_actions.py b/datasette/default_actions.py new file mode 100644 index 00000000..e2050111 --- /dev/null +++ b/datasette/default_actions.py @@ -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, + ), + ) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index a9534cab..a07775b6 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -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( diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index eedb2481..5477a407 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -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. diff --git a/datasette/permissions.py b/datasette/permissions.py index bd42158e..b769b24c 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -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 diff --git a/datasette/plugins.py b/datasette/plugins.py index 3769a209..288c536b 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -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", diff --git a/datasette/resources.py b/datasette/resources.py new file mode 100644 index 00000000..f1cff82c --- /dev/null +++ b/datasette/resources.py @@ -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" diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js new file mode 100644 index 00000000..ec4d2970 --- /dev/null +++ b/datasette/static/navigation-search.js @@ -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 = ` + + + +
+
+ +
+
+
+ Navigate + Enter Select + Esc Close +
+
+
+ `; + } + + 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 = `
${message}
`; + return; + } + + container.innerHTML = this.matches + .map( + (match, index) => ` +
+
+
${this.escapeHtml( + match.name + )}
+
${this.escapeHtml(match.url)}
+
+
+ ` + ) + .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); diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 0b2def5a..0d89e11c 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -72,5 +72,7 @@ {% endfor %} {% if select_templates %}{% endif %} + + diff --git a/datasette/utils/actions_sql.py b/datasette/utils/actions_sql.py new file mode 100644 index 00000000..4088e988 --- /dev/null +++ b/datasette/utils/actions_sql.py @@ -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 diff --git a/datasette/utils/permissions.py b/datasette/utils/permissions.py index 7dc2eb4d..169f786c 100644 --- a/datasette/utils/permissions.py +++ b/datasette/utils/permissions.py @@ -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] = [] diff --git a/datasette/views/database.py b/datasette/views/database.py index 6d320d41..280b9b81 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -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( diff --git a/datasette/views/index.py b/datasette/views/index.py index 63cc067d..2939f98e 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -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: diff --git a/datasette/views/special.py b/datasette/views/special.py index 7e5ce517..b825c1c0 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -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}) diff --git a/docs/authentication.rst b/docs/authentication.rst index d16a7230..2f72e89a 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -28,7 +28,17 @@ Using the "root" actor Datasette currently leaves almost all forms of authentication to plugins - `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 ` has this permission by default. +**Requires the permissions-debug permission** - this endpoint returns a 403 Forbidden error for users without this permission. .. _PermissionCheckView: diff --git a/docs/internals.rst b/docs/internals.rst index 8575ac14..d0522e8a 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -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 diff --git a/docs/introspection.rst b/docs/introspection.rst index ff78ec78..19c6bffb 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -144,6 +144,47 @@ Shows currently attached databases. `Databases example `_: + +.. 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 diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 244f448d..5c72c165 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -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 = 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 diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 04e61261..3a76e655 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -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() diff --git a/tests/test_auth.py b/tests/test_auth.py index e9ba5b1c..f9198169 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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" diff --git a/tests/test_cli.py b/tests/test_cli.py index 17f7c1f9..a18c8f09 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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)) diff --git a/tests/test_config_dir.py b/tests/test_config_dir.py index 46a6d341..0598a4a6 100644 --- a/tests/test_config_dir.py +++ b/tests/test_config_dir.py @@ -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") diff --git a/tests/test_html.py b/tests/test_html.py index 6c838549..493dbdd8 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -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})} diff --git a/tests/test_permission_endpoints.py b/tests/test_permission_endpoints.py index 3952259e..33e7cd75 100644 --- a/tests/test_permission_endpoints.py +++ b/tests/test_permission_endpoints.py @@ -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 "" in text or "= 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} diff --git a/tests/test_utils_permissions.py b/tests/test_utils_permissions.py index 81fbfacd..937a7a57 100644 --- a/tests/test_utils_permissions.py +++ b/tests/test_utils_permissions.py @@ -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