diff --git a/datasette/app.py b/datasette/app.py index 6c7026a8..225d66e4 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, @@ -308,6 +309,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: @@ -589,6 +591,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 ): @@ -1242,6 +1271,107 @@ class Datasette: # It's visible to everyone return True, False + async def allowed_resources( + self, + action: str, + actor: dict | None = None, + ) -> 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. + + Example: + tables = await datasette.allowed_resources("view-table", actor) + for table in tables: + print(f"{table.parent}/{table.child}") + """ + from datasette.utils.actions_sql import build_allowed_resources_sql + from datasette.permissions import Resource + + action_obj = self.actions.get(action) + if not action_obj: + raise ValueError(f"Unknown action: {action}") + + query, params = await build_allowed_resources_sql(self, actor, action) + 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) + # Create instance directly with parent/child from base class + resource = object.__new__(resource_class) + Resource.__init__(resource, parent=row[0], child=row[1]) + 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.utils.actions_sql import build_allowed_resources_sql + 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 build_allowed_resources_sql(self, actor, action) + 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.default_actions import TableResource + can_view = await datasette.allowed( + "view-table", + TableResource(database="analytics", table="users"), + 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 +1856,10 @@ class Datasette: ApiExplorerView.as_view(self), r"/-/api$", ) + add_route( + TablesView.as_view(self), + r"/-/tables$", + ) add_route( LogoutView.as_view(self), r"/-/logout$", diff --git a/datasette/default_actions.py b/datasette/default_actions.py new file mode 100644 index 00000000..53916259 --- /dev/null +++ b/datasette/default_actions.py @@ -0,0 +1,189 @@ +from datasette import hookimpl +from datasette.permissions import Action, Resource +from typing import Optional + + +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 + """ + + +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" + + +@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..25bc9590 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -289,6 +289,13 @@ async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]: db_allow_sql = db_config.get("allow_sql") add_row(db_name, None, evaluate(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( + db_name, None, evaluate(db_allow), f"allow for {action} on {db_name}" + ) + if action == "view-instance": allow_block = config.get("allow") add_row(None, None, evaluate(allow_block), "allow for view-instance") @@ -325,7 +332,6 @@ 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)] diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index eedb2481..35c4062d 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""" diff --git a/datasette/permissions.py b/datasette/permissions.py index bd42158e..f83780e6 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -1,7 +1,92 @@ +from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Optional +from typing import 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 + + @classmethod + @abstractmethod + def resources_sql(cls) -> str: + """ + Return SQL query that returns all resources of this type. + + Must return two columns: parent, child + """ + pass + + def __str__(self) -> str: + if self.parent is None and self.child is None: + return f"{self.name}:*" + elif self.child is None: + return f"{self.name}:{self.parent}" + else: + return f"{self.name}:{self.parent}/{self.child}" + + def __repr__(self) -> str: + parts = [f"{self.__class__.__name__}("] + args = [] + if self.parent: + args.append(f"{self.parent!r}") + if self.child: + args.append(f"{self.child!r}") + parts.append(", ".join(args)) + parts.append(")") + return "".join(parts) + + def __eq__(self, other): + if not isinstance(other, Resource): + return False + return ( + self.__class__ == other.__class__ + and self.parent == other.parent + and self.child == other.child + ) + + def __hash__(self): + return hash((self.__class__, self.parent, self.child)) + + +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] + + +# 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/static/navigation-search.js b/datasette/static/navigation-search.js new file mode 100644 index 00000000..202839d5 --- /dev/null +++ b/datasette/static/navigation-search.js @@ -0,0 +1,401 @@ +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 = ` + + + + `; + } + + 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 = `