datasette serve --default-deny option (#2593)

Closes #2592
This commit is contained in:
Simon Willison 2025-11-12 16:14:21 -08:00 committed by GitHub
commit 23a640d38b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 177 additions and 0 deletions

View file

@ -304,6 +304,7 @@ class Datasette:
crossdb=False,
nolock=False,
internal=None,
default_deny=False,
):
self._startup_invoked = False
assert config_dir is None or isinstance(
@ -512,6 +513,7 @@ class Datasette:
self._permission_checks = collections.deque(maxlen=200)
self._root_token = secrets.token_hex(32)
self.root_enabled = False
self.default_deny = default_deny
self.client = DatasetteClient(self)
async def apply_metadata_json(self):

View file

@ -438,6 +438,11 @@ def uninstall(packages, yes):
help="Output URL that sets a cookie authenticating the root user",
is_flag=True,
)
@click.option(
"--default-deny",
help="Deny all permissions by default",
is_flag=True,
)
@click.option(
"--get",
help="Run an HTTP GET request against this path, print results and exit",
@ -514,6 +519,7 @@ def serve(
settings,
secret,
root,
default_deny,
get,
headers,
token,
@ -594,6 +600,7 @@ def serve(
crossdb=crossdb,
nolock=nolock,
internal=internal,
default_deny=default_deny,
)
# Separate directories from files

View file

@ -352,6 +352,10 @@ async def default_action_permissions_sql(datasette, actor, action):
With the INTERSECT-based restriction approach, these defaults are always generated
and then filtered by restriction_sql if the actor has restrictions.
"""
# Skip default allow rules if default_deny is enabled
if datasette.default_deny:
return None
default_allow_actions = {
"view-instance",
"view-database",

View file

@ -83,6 +83,39 @@ Datasette's built-in view actions (``view-database``, ``view-table`` etc) are al
Other actions, including those introduced by plugins, will default to *deny*.
.. _authentication_default_deny:
Denying all permissions by default
----------------------------------
By default, Datasette allows unauthenticated access to view databases, tables, and execute SQL queries.
You may want to run Datasette in a mode where **all** access is denied by default, and you explicitly grant permissions only to authenticated users, either using the :ref:`--root mechanism <authentication_root>` or through :ref:`configuration file rules <authentication_permissions_config>` or plugins.
Use the ``--default-deny`` command-line option to run Datasette in this mode::
datasette --default-deny data.db --root
With ``--default-deny`` enabled:
* Anonymous users are denied access to view the instance, databases, tables, and queries
* Authenticated users are also denied access unless they're explicitly granted permissions
* The root user (when using ``--root``) still has access to everything
* You can grant permissions using :ref:`configuration file rules <authentication_permissions_config>` or plugins
For example, to allow only a specific user to access your instance::
datasette --default-deny data.db --config datasette.yaml
Where ``datasette.yaml`` contains:
.. code-block:: yaml
allow:
id: alice
This configuration will deny access to everyone except the user with ``id`` of ``alice``.
.. _authentication_permissions_explained:
How permissions are resolved

View file

@ -119,6 +119,7 @@ Once started you can access it at ``http://localhost:8001``
signed cookies
--root Output URL that sets a cookie authenticating
the root user
--default-deny Deny all permissions by default
--get TEXT Run an HTTP GET request against this path,
print results and exit
--headers Include HTTP headers in --get output

View file

@ -142,6 +142,7 @@ def test_metadata_yaml():
settings=[],
secret=None,
root=False,
default_deny=False,
token=None,
actor=None,
version_note=None,

129
tests/test_default_deny.py Normal file
View file

@ -0,0 +1,129 @@
import pytest
from datasette.app import Datasette
from datasette.resources import DatabaseResource, TableResource
@pytest.mark.asyncio
async def test_default_deny_denies_default_permissions():
"""Test that default_deny=True denies default permissions"""
# Without default_deny, anonymous users can view instance/database/tables
ds_normal = Datasette()
await ds_normal.invoke_startup()
# Add a test database
db = ds_normal.add_memory_database("test_db_normal")
await db.execute_write("create table test_table (id integer primary key)")
await ds_normal._refresh_schemas() # Trigger catalog refresh
# Test default behavior - anonymous user should be able to view
response = await ds_normal.client.get("/")
assert response.status_code == 200
response = await ds_normal.client.get("/test_db_normal")
assert response.status_code == 200
response = await ds_normal.client.get("/test_db_normal/test_table")
assert response.status_code == 200
# With default_deny=True, anonymous users should be denied
ds_deny = Datasette(default_deny=True)
await ds_deny.invoke_startup()
# Add the same test database
db = ds_deny.add_memory_database("test_db_deny")
await db.execute_write("create table test_table (id integer primary key)")
await ds_deny._refresh_schemas() # Trigger catalog refresh
# Anonymous user should be denied
response = await ds_deny.client.get("/")
assert response.status_code == 403
response = await ds_deny.client.get("/test_db_deny")
assert response.status_code == 403
response = await ds_deny.client.get("/test_db_deny/test_table")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_default_deny_with_root_user():
"""Test that root user still has access when default_deny=True"""
ds = Datasette(default_deny=True)
ds.root_enabled = True
await ds.invoke_startup()
root_actor = {"id": "root"}
# Root user should have all permissions even with default_deny
assert await ds.allowed(action="view-instance", actor=root_actor) is True
assert (
await ds.allowed(
action="view-database",
actor=root_actor,
resource=DatabaseResource("test_db"),
)
is True
)
assert (
await ds.allowed(
action="view-table",
actor=root_actor,
resource=TableResource("test_db", "test_table"),
)
is True
)
assert (
await ds.allowed(
action="execute-sql", actor=root_actor, resource=DatabaseResource("test_db")
)
is True
)
@pytest.mark.asyncio
async def test_default_deny_with_config_allow():
"""Test that config allow rules still work with default_deny=True"""
ds = Datasette(default_deny=True, config={"allow": {"id": "user1"}})
await ds.invoke_startup()
# Anonymous user should be denied
assert await ds.allowed(action="view-instance", actor=None) is False
# Authenticated user with explicit permission should have access
assert await ds.allowed(action="view-instance", actor={"id": "user1"}) is True
# Different user should be denied
assert await ds.allowed(action="view-instance", actor={"id": "user2"}) is False
@pytest.mark.asyncio
async def test_default_deny_basic_permissions():
"""Test that default_deny=True denies basic permissions"""
ds = Datasette(default_deny=True)
await ds.invoke_startup()
# Anonymous user should be denied all default permissions
assert await ds.allowed(action="view-instance", actor=None) is False
assert (
await ds.allowed(
action="view-database", actor=None, resource=DatabaseResource("test_db")
)
is False
)
assert (
await ds.allowed(
action="view-table",
actor=None,
resource=TableResource("test_db", "test_table"),
)
is False
)
assert (
await ds.allowed(
action="execute-sql", actor=None, resource=DatabaseResource("test_db")
)
is False
)
# Authenticated user without explicit permission should also be denied
assert await ds.allowed(action="view-instance", actor={"id": "user"}) is False