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,
|
crossdb=False,
|
||||||
nolock=False,
|
nolock=False,
|
||||||
internal=None,
|
internal=None,
|
||||||
|
default_deny=False,
|
||||||
):
|
):
|
||||||
self._startup_invoked = False
|
self._startup_invoked = False
|
||||||
assert config_dir is None or isinstance(
|
assert config_dir is None or isinstance(
|
||||||
|
|
@ -512,6 +513,7 @@ class Datasette:
|
||||||
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.root_enabled = False
|
||||||
|
self.default_deny = default_deny
|
||||||
self.client = DatasetteClient(self)
|
self.client = DatasetteClient(self)
|
||||||
|
|
||||||
async def apply_metadata_json(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",
|
help="Output URL that sets a cookie authenticating the root user",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--default-deny",
|
||||||
|
help="Deny all permissions by default",
|
||||||
|
is_flag=True,
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--get",
|
"--get",
|
||||||
help="Run an HTTP GET request against this path, print results and exit",
|
help="Run an HTTP GET request against this path, print results and exit",
|
||||||
|
|
@ -514,6 +519,7 @@ def serve(
|
||||||
settings,
|
settings,
|
||||||
secret,
|
secret,
|
||||||
root,
|
root,
|
||||||
|
default_deny,
|
||||||
get,
|
get,
|
||||||
headers,
|
headers,
|
||||||
token,
|
token,
|
||||||
|
|
@ -594,6 +600,7 @@ def serve(
|
||||||
crossdb=crossdb,
|
crossdb=crossdb,
|
||||||
nolock=nolock,
|
nolock=nolock,
|
||||||
internal=internal,
|
internal=internal,
|
||||||
|
default_deny=default_deny,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Separate directories from files
|
# 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
|
With the INTERSECT-based restriction approach, these defaults are always generated
|
||||||
and then filtered by restriction_sql if the actor has restrictions.
|
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 = {
|
default_allow_actions = {
|
||||||
"view-instance",
|
"view-instance",
|
||||||
"view-database",
|
"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*.
|
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:
|
.. _authentication_permissions_explained:
|
||||||
|
|
||||||
How permissions are resolved
|
How permissions are resolved
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ Once started you can access it at ``http://localhost:8001``
|
||||||
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
|
||||||
|
--default-deny Deny all permissions by default
|
||||||
--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
|
||||||
--headers Include HTTP headers in --get output
|
--headers Include HTTP headers in --get output
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ def test_metadata_yaml():
|
||||||
settings=[],
|
settings=[],
|
||||||
secret=None,
|
secret=None,
|
||||||
root=False,
|
root=False,
|
||||||
|
default_deny=False,
|
||||||
token=None,
|
token=None,
|
||||||
actor=None,
|
actor=None,
|
||||||
version_note=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