Prototype of default deny modes, refs #2540

This commit is contained in:
Simon Willison 2025-10-30 11:07:05 -07:00
commit 335814a753
4 changed files with 192 additions and 20 deletions

View file

@ -296,6 +296,8 @@ class Datasette:
crossdb=False, crossdb=False,
nolock=False, nolock=False,
internal=None, internal=None,
private=False,
require_auth=False,
): ):
self._startup_invoked = False self._startup_invoked = False
assert config_dir is None or isinstance( assert config_dir is None or isinstance(
@ -340,6 +342,8 @@ class Datasette:
raise raise
self.crossdb = crossdb self.crossdb = crossdb
self.nolock = nolock self.nolock = nolock
self.private = private
self.require_auth = require_auth
if memory or crossdb or not self.files: if memory or crossdb or not self.files:
self.add_database( self.add_database(
Database(self, is_mutable=False, is_memory=True), name="_memory" Database(self, is_mutable=False, is_memory=True), name="_memory"

View file

@ -490,6 +490,16 @@ def uninstall(packages, yes):
type=click.Path(), type=click.Path(),
help="Path to a persistent Datasette internal SQLite database", 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( def serve(
files, files,
immutable, immutable,
@ -522,6 +532,8 @@ def serve(
ssl_keyfile, ssl_keyfile,
ssl_certfile, ssl_certfile,
internal, internal,
private,
require_auth,
return_instance=False, return_instance=False,
): ):
"""Serve up specified SQLite database files with a web UI""" """Serve up specified SQLite database files with a web UI"""
@ -536,6 +548,8 @@ def serve(
) )
click.echo(formatter.getvalue()) click.echo(formatter.getvalue())
sys.exit(0) sys.exit(0)
if private and require_auth:
raise click.UsageError("Cannot use both --private and --require-auth")
if reload: if reload:
import hupper import hupper
@ -588,6 +602,8 @@ def serve(
crossdb=crossdb, crossdb=crossdb,
nolock=nolock, nolock=nolock,
internal=internal, internal=internal,
private=private,
require_auth=require_auth,
) )
# if files is a single directory, use that as config_dir= # if files is a single directory, use that as config_dir=

View file

@ -60,30 +60,55 @@ async def permission_resources_sql(datasette, actor, action):
return rules return rules
# 5. Default allow actions (ONLY if no restrictions) # 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 # If actor has restrictions, they've already added their own deny/allow rules
has_restrictions = actor and "_r" in actor has_restrictions = actor and "_r" in actor
if not has_restrictions: if not has_restrictions:
default_allow_actions = { # Check for --private flag (complete default-deny mode)
"view-instance", if datasette.private:
"view-database", # In private mode, don't grant any default allow permissions
"view-database-download", pass
"view-table", # Check for --require-auth flag (authenticated-only mode)
"view-query", elif datasette.require_auth:
"execute-sql", # Only grant default allow if actor has an id (is authenticated)
} if actor and actor.get("id"):
if action in default_allow_actions: if action in default_allow_actions:
reason = f"default allow for {action}".replace("'", "''") reason = f"default allow for {action} (authenticated)".replace(
sql = ( "'", "''"
"SELECT NULL AS parent, NULL AS child, 1 AS allow, " )
f"'{reason}' AS reason" sql = (
) "SELECT NULL AS parent, NULL AS child, 1 AS allow, "
rules.append( f"'{reason}' AS reason"
PermissionSQL( )
source="default_permissions", rules.append(
sql=sql, PermissionSQL(
params={}, 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: if not rules:
return None return None

View file

@ -161,3 +161,130 @@ async def test_view_instance_allow_block():
assert await ds.allowed(action="view-instance", actor={"id": "alice"}) assert await ds.allowed(action="view-instance", actor={"id": "alice"})
assert not await ds.allowed(action="view-instance", actor={"id": "bob"}) 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"},
)