diff --git a/datasette/app.py b/datasette/app.py index bfbf2360..980da6b8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -296,6 +296,8 @@ class Datasette: crossdb=False, nolock=False, internal=None, + private=False, + require_auth=False, ): self._startup_invoked = False assert config_dir is None or isinstance( @@ -340,6 +342,8 @@ class Datasette: raise self.crossdb = crossdb self.nolock = nolock + self.private = private + self.require_auth = require_auth if memory or crossdb or not self.files: self.add_database( Database(self, is_mutable=False, is_memory=True), name="_memory" diff --git a/datasette/cli.py b/datasette/cli.py index 24d87279..6a2c1623 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -490,6 +490,16 @@ def uninstall(packages, yes): type=click.Path(), help="Path to a persistent Datasette internal SQLite database", ) +@click.option( + "--private", + is_flag=True, + help="Default deny mode - all access blocked unless explicitly allowed", +) +@click.option( + "--require-auth", + is_flag=True, + help="Require authentication - only actors with an id can access", +) def serve( files, immutable, @@ -522,6 +532,8 @@ def serve( ssl_keyfile, ssl_certfile, internal, + private, + require_auth, return_instance=False, ): """Serve up specified SQLite database files with a web UI""" @@ -536,6 +548,8 @@ def serve( ) click.echo(formatter.getvalue()) sys.exit(0) + if private and require_auth: + raise click.UsageError("Cannot use both --private and --require-auth") if reload: import hupper @@ -588,6 +602,8 @@ def serve( crossdb=crossdb, nolock=nolock, internal=internal, + private=private, + require_auth=require_auth, ) # if files is a single directory, use that as config_dir= diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 32164260..ee477c6e 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -60,30 +60,55 @@ async def permission_resources_sql(datasette, actor, action): return rules # 5. Default allow actions (ONLY if no restrictions) + default_allow_actions = { + "view-instance", + "view-database", + "view-database-download", + "view-table", + "view-query", + "execute-sql", + } # If actor has restrictions, they've already added their own deny/allow rules has_restrictions = actor and "_r" in actor if not has_restrictions: - default_allow_actions = { - "view-instance", - "view-database", - "view-database-download", - "view-table", - "view-query", - "execute-sql", - } - if action in default_allow_actions: - reason = f"default allow for {action}".replace("'", "''") - sql = ( - "SELECT NULL AS parent, NULL AS child, 1 AS allow, " - f"'{reason}' AS reason" - ) - rules.append( - PermissionSQL( - source="default_permissions", - sql=sql, - params={}, + # Check for --private flag (complete default-deny mode) + if datasette.private: + # In private mode, don't grant any default allow permissions + pass + # Check for --require-auth flag (authenticated-only mode) + elif datasette.require_auth: + # Only grant default allow if actor has an id (is authenticated) + if actor and actor.get("id"): + if action in default_allow_actions: + reason = f"default allow for {action} (authenticated)".replace( + "'", "''" + ) + sql = ( + "SELECT NULL AS parent, NULL AS child, 1 AS allow, " + f"'{reason}' AS reason" + ) + rules.append( + PermissionSQL( + source="default_permissions", + sql=sql, + params={}, + ) + ) + else: + # Normal mode - grant default allow to everyone + if action in default_allow_actions: + reason = f"default allow for {action}".replace("'", "''") + sql = ( + "SELECT NULL AS parent, NULL AS child, 1 AS allow, " + f"'{reason}' AS reason" + ) + rules.append( + PermissionSQL( + source="default_permissions", + sql=sql, + params={}, + ) ) - ) if not rules: return None diff --git a/tests/test_config_permission_rules.py b/tests/test_config_permission_rules.py index 8327ecbf..f8b13ee9 100644 --- a/tests/test_config_permission_rules.py +++ b/tests/test_config_permission_rules.py @@ -161,3 +161,130 @@ async def test_view_instance_allow_block(): assert await ds.allowed(action="view-instance", actor={"id": "alice"}) assert not await ds.allowed(action="view-instance", actor={"id": "bob"}) + + +@pytest.mark.asyncio +async def test_private_mode_denies_all_by_default(): + """Test --private flag blocks all access unless explicitly allowed""" + ds = Datasette(memory=True, private=True) + ds.add_database(Database(ds, memory_name="test_memory"), name="test") + await ds.invoke_startup() + await ds.refresh_schemas() + + # Unauthenticated access should be denied for all default actions + assert not await ds.allowed(action="view-instance", actor=None) + assert not await ds.allowed( + action="view-database", resource=DatabaseResource(database="test"), actor=None + ) + assert not await ds.allowed( + action="view-table", + resource=TableResource(database="test", table="test"), + actor=None, + ) + + # Even authenticated users should be denied in private mode + assert not await ds.allowed(action="view-instance", actor={"id": "alice"}) + assert not await ds.allowed( + action="view-database", + resource=DatabaseResource(database="test"), + actor={"id": "alice"}, + ) + + +@pytest.mark.asyncio +async def test_private_mode_with_explicit_allow(): + """Test --private flag allows explicitly configured permissions""" + config = {"permissions": {"view-instance": {"id": "alice"}}} + ds = Datasette(memory=True, private=True, config=config) + ds.add_database(Database(ds, memory_name="test_memory"), name="test") + await ds.invoke_startup() + await ds.refresh_schemas() + + # Alice should be allowed due to explicit config + assert await ds.allowed(action="view-instance", actor={"id": "alice"}) + + # Bob should still be denied + assert not await ds.allowed(action="view-instance", actor={"id": "bob"}) + + # Unauthenticated should be denied + assert not await ds.allowed(action="view-instance", actor=None) + + +@pytest.mark.asyncio +async def test_require_auth_mode_allows_authenticated(): + """Test --require-auth flag allows actors with id""" + ds = Datasette(memory=True, require_auth=True) + ds.add_database(Database(ds, memory_name="test_memory"), name="test") + await ds.invoke_startup() + await ds.refresh_schemas() + + # Authenticated users should be allowed + assert await ds.allowed(action="view-instance", actor={"id": "alice"}) + assert await ds.allowed( + action="view-database", + resource=DatabaseResource(database="test"), + actor={"id": "bob"}, + ) + assert await ds.allowed( + action="view-table", + resource=TableResource(database="test", table="test"), + actor={"id": "charlie"}, + ) + + # Unauthenticated access should be denied + assert not await ds.allowed(action="view-instance", actor=None) + assert not await ds.allowed( + action="view-database", resource=DatabaseResource(database="test"), actor=None + ) + + # Actor without id should be denied + assert not await ds.allowed(action="view-instance", actor={"name": "anonymous"}) + + +@pytest.mark.asyncio +async def test_require_auth_mode_with_restrictions(): + """Test --require-auth mode works with actor restrictions""" + # Test with actor that has restrictions + ds = Datasette(memory=True, require_auth=True) + ds.add_database(Database(ds, memory_name="test_memory"), name="test") + await ds.invoke_startup() + await ds.refresh_schemas() + + # Actor with restrictions should have those restrictions applied + restricted_actor = {"id": "alice", "_r": {"a": ["view-table"]}} + # This actor has restrictions, so default allow won't apply + # Instead their restrictions define what they can do + assert await ds.allowed( + action="view-table", + resource=TableResource(database="test", table="test"), + actor=restricted_actor, + ) + + # Regular authenticated actor without restrictions should get default allow + normal_actor = {"id": "bob"} + assert await ds.allowed( + action="view-database", + resource=DatabaseResource(database="test"), + actor=normal_actor, + ) + + +@pytest.mark.asyncio +async def test_normal_mode_allows_all(): + """Test default behavior without --private or --require-auth""" + ds = Datasette(memory=True, private=False, require_auth=False) + ds.add_database(Database(ds, memory_name="test_memory"), name="test") + await ds.invoke_startup() + await ds.refresh_schemas() + + # Everyone should be allowed in normal mode + assert await ds.allowed(action="view-instance", actor=None) + assert await ds.allowed( + action="view-database", resource=DatabaseResource(database="test"), actor=None + ) + assert await ds.allowed(action="view-instance", actor={"id": "alice"}) + assert await ds.allowed( + action="view-database", + resource=DatabaseResource(database="test"), + actor={"id": "bob"}, + )