diff --git a/datasette/app.py b/datasette/app.py index 241bec2f..ce3ac337 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -460,6 +460,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): 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_permissions.py b/datasette/default_permissions.py index d8efffa4..710c73c9 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -127,23 +127,26 @@ def register_permissions(): ) +@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(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 - # Resolve view permissions in allow blocks in configuration if action in ( "view-instance", @@ -174,6 +177,22 @@ def permission_allowed_default(datasette, actor, action, resource): @hookimpl async def permission_resources_sql(datasette, actor, action): + # Root user with root_enabled gets all permissions + if datasette.root_enabled and actor and actor.get("id") == "root": + # Return SQL that grants access to ALL resources for this action + action_obj = datasette.actions.get(action) + if action_obj and action_obj.resource_class: + resources_sql = action_obj.resource_class.resources_sql() + sql = f""" + SELECT parent, child, 1 AS allow, 'root user' AS reason + FROM ({resources_sql}) + """ + return PermissionSQL( + source="root_permissions", + sql=sql, + params={}, + ) + rules: list[PermissionSQL] = [] config_rules = await _config_permission_rules(datasette, actor, action) @@ -263,21 +282,23 @@ async def _config_permission_rules(datasette, actor, action) -> list[PermissionS ) 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( + db_name, + query_name, + evaluate(query_allow), + f"allow for {action} on {db_name}/{query_name}", + ) if action == "view-database": db_allow = db_config.get("allow") 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/cli-reference.rst b/docs/cli-reference.rst index 67e06254..54c068b7 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -118,7 +118,7 @@ Once started you can access it at ``http://localhost:8001`` --secret TEXT Secret used for signing secure values, such as signed cookies --root Output URL that sets a cookie authenticating - the root user + the root user with all permissions --get TEXT Run an HTTP GET request against this path, print results and exit --token TEXT API token to send with --get requests diff --git a/tests/test_auth.py b/tests/test_auth.py index e9ba5b1c..18736002 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,123 @@ 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_permissions.py b/tests/test_permissions.py index c5139d20..25dce2c9 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -375,7 +375,8 @@ def test_permissions_checked(app_client, path, permissions): async def test_permissions_debug(ds_client, filter_): ds_client.ds._permission_checks.clear() assert (await ds_client.get("/-/permissions")).status_code == 403 - # With the cookie it should work + # With the cookie it should work (need to set root_enabled for root user) + ds_client.ds.root_enabled = True cookie = ds_client.actor_cookie({"id": "root"}) response = await ds_client.get( f"/-/permissions?filter={filter_}", cookies={"ds_actor": cookie} @@ -418,8 +419,8 @@ async def test_permissions_debug(ds_client, filter_): }, { "action": "view-instance", - "result": None, - "used_default": True, + "result": True, + "used_default": False, "actor": {"id": "root"}, }, {"action": "debug-menu", "result": False, "used_default": True, "actor": None}, @@ -691,6 +692,7 @@ async def test_actor_restricted_permissions( perms_ds, actor, permission, resource_1, resource_2, expected_result ): perms_ds.pdb = True + perms_ds.root_enabled = True # Allow root actor to access /-/permissions cookies = {"ds_actor": perms_ds.sign({"a": {"id": "root"}}, "actor")} csrftoken = (await perms_ds.client.get("/-/permissions", cookies=cookies)).cookies[ "ds_csrftoken"