mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
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:
parent
e539c1c024
commit
8bf06a76b5
20 changed files with 513 additions and 88 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue