mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
parent
32a425868c
commit
23a640d38b
7 changed files with 177 additions and 0 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
129
tests/test_default_deny.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue