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,
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue