mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Prototype of default deny modes, refs #2540
This commit is contained in:
parent
ce4b0794b2
commit
335814a753
4 changed files with 192 additions and 20 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue