mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
New --root mechanism with datasette.root_enabled, closes #2521
This commit is contained in:
parent
1d37d30c2a
commit
5fc58c8775
7 changed files with 191 additions and 31 deletions
|
|
@ -460,6 +460,7 @@ class Datasette:
|
||||||
self._register_renderers()
|
self._register_renderers()
|
||||||
self._permission_checks = collections.deque(maxlen=200)
|
self._permission_checks = collections.deque(maxlen=200)
|
||||||
self._root_token = secrets.token_hex(32)
|
self._root_token = secrets.token_hex(32)
|
||||||
|
self.root_enabled = False
|
||||||
self.client = DatasetteClient(self)
|
self.client = DatasetteClient(self)
|
||||||
|
|
||||||
async def apply_metadata_json(self):
|
async def apply_metadata_json(self):
|
||||||
|
|
|
||||||
|
|
@ -648,6 +648,7 @@ def serve(
|
||||||
# Start the server
|
# Start the server
|
||||||
url = None
|
url = None
|
||||||
if root:
|
if root:
|
||||||
|
ds.root_enabled = True
|
||||||
url = "http://{}:{}{}?token={}".format(
|
url = "http://{}:{}{}?token={}".format(
|
||||||
host, port, ds.urls.path("-/auth-token"), ds._root_token
|
host, port, ds.urls.path("-/auth-token"), ds._root_token
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
@hookimpl(tryfirst=True, specname="permission_allowed")
|
||||||
def permission_allowed_default(datasette, actor, action, resource):
|
def permission_allowed_default(datasette, actor, action, resource):
|
||||||
async def inner():
|
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
|
# Resolve view permissions in allow blocks in configuration
|
||||||
if action in (
|
if action in (
|
||||||
"view-instance",
|
"view-instance",
|
||||||
|
|
@ -174,6 +177,22 @@ def permission_allowed_default(datasette, actor, action, resource):
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
async def permission_resources_sql(datasette, actor, action):
|
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] = []
|
rules: list[PermissionSQL] = []
|
||||||
|
|
||||||
config_rules = await _config_permission_rules(datasette, actor, action)
|
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():
|
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||||
query_perm = (query_config.get("permissions") or {}).get(action)
|
# query_config can be a string (just SQL) or a dict (with SQL and options)
|
||||||
add_row(
|
if isinstance(query_config, dict):
|
||||||
db_name,
|
query_perm = (query_config.get("permissions") or {}).get(action)
|
||||||
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")
|
|
||||||
add_row(
|
add_row(
|
||||||
db_name,
|
db_name,
|
||||||
query_name,
|
query_name,
|
||||||
evaluate(query_allow),
|
evaluate(query_perm),
|
||||||
f"allow for {action} on {db_name}/{query_name}",
|
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":
|
if action == "view-database":
|
||||||
db_allow = db_config.get("allow")
|
db_allow = db_config.get("allow")
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,17 @@ Using the "root" actor
|
||||||
|
|
||||||
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.
|
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/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::
|
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.
|
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 <authentication_root>` has this permission by default.
|
**Requires the permissions-debug permission** - this endpoint returns a 403 Forbidden error for users without this permission.
|
||||||
|
|
||||||
.. _PermissionCheckView:
|
.. _PermissionCheckView:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ Once started you can access it at ``http://localhost:8001``
|
||||||
--secret TEXT Secret used for signing secure values, such as
|
--secret TEXT Secret used for signing secure values, such as
|
||||||
signed cookies
|
signed cookies
|
||||||
--root Output URL that sets a cookie authenticating
|
--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,
|
--get TEXT Run an HTTP GET request against this path,
|
||||||
print results and exit
|
print results and exit
|
||||||
--token TEXT API token to send with --get requests
|
--token TEXT API token to send with --get requests
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ from .utils import cookie_was_deleted, last_event
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
from datasette.utils import baseconv
|
from datasette.utils import baseconv
|
||||||
from datasette.cli import cli
|
from datasette.cli import cli
|
||||||
|
from datasette.resources import (
|
||||||
|
InstanceResource,
|
||||||
|
DatabaseResource,
|
||||||
|
TableResource,
|
||||||
|
)
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
@ -337,3 +342,123 @@ def test_cli_create_token(app_client, expires):
|
||||||
else:
|
else:
|
||||||
expected_actor = None
|
expected_actor = None
|
||||||
assert response.json == {"actor": expected_actor}
|
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"
|
||||||
|
|
|
||||||
|
|
@ -375,7 +375,8 @@ def test_permissions_checked(app_client, path, permissions):
|
||||||
async def test_permissions_debug(ds_client, filter_):
|
async def test_permissions_debug(ds_client, filter_):
|
||||||
ds_client.ds._permission_checks.clear()
|
ds_client.ds._permission_checks.clear()
|
||||||
assert (await ds_client.get("/-/permissions")).status_code == 403
|
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"})
|
cookie = ds_client.actor_cookie({"id": "root"})
|
||||||
response = await ds_client.get(
|
response = await ds_client.get(
|
||||||
f"/-/permissions?filter={filter_}", cookies={"ds_actor": cookie}
|
f"/-/permissions?filter={filter_}", cookies={"ds_actor": cookie}
|
||||||
|
|
@ -418,8 +419,8 @@ async def test_permissions_debug(ds_client, filter_):
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"action": "view-instance",
|
"action": "view-instance",
|
||||||
"result": None,
|
"result": True,
|
||||||
"used_default": True,
|
"used_default": False,
|
||||||
"actor": {"id": "root"},
|
"actor": {"id": "root"},
|
||||||
},
|
},
|
||||||
{"action": "debug-menu", "result": False, "used_default": True, "actor": None},
|
{"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, actor, permission, resource_1, resource_2, expected_result
|
||||||
):
|
):
|
||||||
perms_ds.pdb = True
|
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")}
|
cookies = {"ds_actor": perms_ds.sign({"a": {"id": "root"}}, "actor")}
|
||||||
csrftoken = (await perms_ds.client.get("/-/permissions", cookies=cookies)).cookies[
|
csrftoken = (await perms_ds.client.get("/-/permissions", cookies=cookies)).cookies[
|
||||||
"ds_csrftoken"
|
"ds_csrftoken"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue