register_permissions() plugin hook (#1940)

* Docs for permissions: in metadata, refs #1636
* Refactor default_permissions.py to help with implementation of #1636
* register_permissions() plugin hook, closes #1939 - also refs #1938
* Tests for register_permissions() hook, refs #1939
* Documentation for datasette.permissions, refs #1939
* permission_allowed() falls back on Permission.default, refs #1939
* Raise StartupError on duplicate permissions
* Allow dupe permisisons if exact matches
This commit is contained in:
Simon Willison 2022-12-12 18:05:54 -08:00 committed by GitHub
commit 8bf06a76b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 513 additions and 88 deletions

View file

@ -1,3 +1,4 @@
from datasette.permissions import Permission
from datasette.version import __version_info__, __version__ # noqa
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
from datasette.utils import actor_matches_allow # noqa

View file

@ -194,6 +194,8 @@ DEFAULT_SETTINGS = {option.name: option.default for option in SETTINGS}
FAVICON_PATH = app_root / "datasette" / "static" / "favicon.png"
DEFAULT_NOT_SET = object()
async def favicon(request, send):
await asgi_send_file(
@ -264,6 +266,7 @@ class Datasette:
self.inspect_data = inspect_data
self.immutables = set(immutables or [])
self.databases = collections.OrderedDict()
self.permissions = {} # .invoke_startup() will populate this
try:
self._refresh_schemas_lock = asyncio.Lock()
except RuntimeError as rex:
@ -430,6 +433,24 @@ class Datasette:
# This must be called for Datasette to be in a usable state
if self._startup_invoked:
return
# Register permissions, but watch out for duplicate name/abbr
names = {}
abbrs = {}
for hook in pm.hook.register_permissions(datasette=self):
if hook:
for p in hook:
if p.name in names and p != names[p.name]:
raise StartupError(
"Duplicate permission name: {}".format(p.name)
)
if p.abbr and p.abbr in abbrs and p != abbrs[p.abbr]:
raise StartupError(
"Duplicate permission abbr: {}".format(p.abbr)
)
names[p.name] = p
if p.abbr:
abbrs[p.abbr] = p
self.permissions[p.name] = p
for hook in pm.hook.prepare_jinja2_environment(
env=self.jinja_env, datasette=self
):
@ -668,9 +689,7 @@ class Datasette:
if request:
actor = request.actor
# Top-level link
if await self.permission_allowed(
actor=actor, action="view-instance", default=True
):
if await self.permission_allowed(actor=actor, action="view-instance"):
crumbs.append({"href": self.urls.instance(), "label": "home"})
# Database link
if database:
@ -678,7 +697,6 @@ class Datasette:
actor=actor,
action="view-database",
resource=database,
default=True,
):
crumbs.append(
{
@ -693,7 +711,6 @@ class Datasette:
actor=actor,
action="view-table",
resource=(database, table),
default=True,
):
crumbs.append(
{
@ -703,9 +720,14 @@ class Datasette:
)
return crumbs
async def permission_allowed(self, actor, action, resource=None, default=False):
async def permission_allowed(
self, actor, action, resource=None, default=DEFAULT_NOT_SET
):
"""Check permissions using the permissions_allowed plugin hook"""
result = None
# Use default from registered permission, if available
if default is DEFAULT_NOT_SET and action in self.permissions:
default = self.permissions[action].default
for check in pm.hook.permission_allowed(
datasette=self,
actor=actor,

View file

@ -1,4 +1,4 @@
from datasette import hookimpl
from datasette import hookimpl, Permission
from datasette.utils import actor_matches_allow
import click
import itsdangerous
@ -6,9 +6,44 @@ import json
import time
@hookimpl
def register_permissions():
return (
# name, abbr, description, takes_database, takes_resource, default
Permission(
"view-instance", "vi", "View Datasette instance", False, False, True
),
Permission("view-database", "vd", "View database", True, False, True),
Permission(
"view-database-download", "vdd", "Download database file", True, False, True
),
Permission("view-table", "vt", "View table", True, True, True),
Permission("view-query", "vq", "View named query results", True, True, True),
Permission(
"execute-sql", "es", "Execute read-only SQL queries", True, False, True
),
Permission(
"permissions-debug",
"pd",
"Access permission debug tool",
False,
False,
False,
),
Permission("debug-menu", "dm", "View debug menu items", False, False, False),
# Write API permissions
Permission("insert-row", "ir", "Insert rows", True, True, False),
Permission("delete-row", "dr", "Delete rows", True, True, False),
Permission("update-row", "ur", "Update rows", True, True, False),
Permission("create-table", "ct", "Create tables", True, False, False),
Permission("drop-table", "dt", "Drop tables", True, True, False),
)
@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",
@ -20,45 +55,72 @@ def permission_allowed_default(datasette, actor, action, resource):
):
if actor and actor.get("id") == "root":
return True
elif action == "view-instance":
allow = datasette.metadata("allow")
if allow is not None:
return actor_matches_allow(actor, allow)
elif action == "view-database":
if resource == "_internal" and (actor is None or actor.get("id") != "root"):
return False
database_allow = datasette.metadata("allow", database=resource)
if database_allow is None:
return None
return actor_matches_allow(actor, database_allow)
elif action == "view-table":
database, table = resource
tables = datasette.metadata("tables", database=database) 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 metadata
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 = datasette.metadata("allow_sql", database=resource)
if database_allow_sql is None:
database_allow_sql = datasette.metadata("allow_sql")
if database_allow_sql is None:
return None
return actor_matches_allow(actor, database_allow_sql)
# Resolve metadata view permissions
if action in (
"view-instance",
"view-database",
"view-table",
"view-query",
"execute-sql",
):
result = await _resolve_metadata_view_permissions(
datasette, actor, action, resource
)
if result is not None:
return result
# Check custom permissions: blocks
return await _resolve_metadata_permissions_blocks(
datasette, actor, action, resource
)
return inner
async def _resolve_metadata_permissions_blocks(datasette, actor, action, resource):
# Check custom permissions: blocks - not yet implemented
return None
async def _resolve_metadata_view_permissions(datasette, actor, action, resource):
if action == "view-instance":
allow = datasette.metadata("allow")
if allow is not None:
return actor_matches_allow(actor, allow)
elif action == "view-database":
if resource == "_internal" and (actor is None or actor.get("id") != "root"):
return False
database_allow = datasette.metadata("allow", database=resource)
if database_allow is None:
return None
return actor_matches_allow(actor, database_allow)
elif action == "view-table":
database, table = resource
tables = datasette.metadata("tables", database=database) 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 metadata
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 = datasette.metadata("allow_sql", database=resource)
if database_allow_sql is None:
database_allow_sql = datasette.metadata("allow_sql")
if database_allow_sql is None:
return None
return actor_matches_allow(actor, database_allow_sql)
@hookimpl(specname="permission_allowed")
def permission_allowed_actor_restrictions(actor, action, resource):
if actor is None:

View file

@ -74,6 +74,11 @@ def register_facet_classes():
"""Register Facet subclasses"""
@hookspec
def register_permissions(datasette):
"""Register permissions: returns a list of datasette.permission.Permission named tuples"""
@hookspec
def register_routes(datasette):
"""Register URL routes: return a list of (regex, view_function) pairs"""

View file

@ -1,19 +1,6 @@
import collections
Permission = collections.namedtuple(
"Permission", ("name", "abbr", "takes_database", "takes_table", "default")
)
PERMISSIONS = (
Permission("view-instance", "vi", False, False, True),
Permission("view-database", "vd", True, False, True),
Permission("view-database-download", "vdd", True, False, True),
Permission("view-table", "vt", True, True, True),
Permission("view-query", "vq", True, True, True),
Permission("insert-row", "ir", True, True, False),
Permission("delete-row", "dr", True, True, False),
Permission("drop-table", "dt", True, True, False),
Permission("execute-sql", "es", True, False, True),
Permission("permissions-debug", "pd", False, False, False),
Permission("debug-menu", "dm", False, False, False),
"Permission",
("name", "abbr", "description", "takes_database", "takes_resource", "default"),
)

View file

@ -138,7 +138,7 @@ class DatabaseView(DataView):
attached_databases = [d.name for d in await db.attached_databases()]
allow_execute_sql = await self.ds.permission_allowed(
request.actor, "execute-sql", database, default=True
request.actor, "execute-sql", database
)
return (
{
@ -375,7 +375,7 @@ class QueryView(DataView):
columns = []
allow_execute_sql = await self.ds.permission_allowed(
request.actor, "execute-sql", database, default=True
request.actor, "execute-sql", database
)
async def extra_template():

View file

@ -142,7 +142,7 @@ class IndexView(BaseView):
"metadata": self.ds.metadata(),
"datasette_version": __version__,
"private": not await self.ds.permission_allowed(
None, "view-instance", default=True
None, "view-instance"
),
},
)

View file

@ -1,8 +1,6 @@
import json
from datasette.permissions import PERMISSIONS
from datasette.utils.asgi import Response, Forbidden
from datasette.utils import actor_matches_allow, add_cors_headers
from datasette.permissions import PERMISSIONS
from .base import BaseView
import secrets
import time
@ -108,7 +106,7 @@ class PermissionsDebugView(BaseView):
# list() avoids error if check is performed during template render:
{
"permission_checks": list(reversed(self.ds._permission_checks)),
"permissions": PERMISSIONS,
"permissions": list(self.ds.permissions.values()),
},
)

View file

@ -864,7 +864,7 @@ class TableView(DataView):
"next_url": next_url,
"private": private,
"allow_execute_sql": await self.ds.permission_allowed(
request.actor, "execute-sql", database_name, default=True
request.actor, "execute-sql", database_name
),
},
extra_template,