2022-12-13 02:05:54 +00:00
|
|
|
from datasette import hookimpl, Permission
|
2020-06-07 21:23:16 +00:00
|
|
|
from datasette.utils import actor_matches_allow
|
2022-10-26 02:18:41 +00:00
|
|
|
import itsdangerous
|
|
|
|
import time
|
2023-08-29 16:32:34 +00:00
|
|
|
from typing import Union, Tuple
|
2020-06-01 05:00:36 +00:00
|
|
|
|
|
|
|
|
2022-12-13 02:05:54 +00:00
|
|
|
@hookimpl
|
|
|
|
def register_permissions():
|
|
|
|
return (
|
|
|
|
Permission(
|
2023-08-29 16:32:34 +00:00
|
|
|
name="view-instance",
|
|
|
|
abbr="vi",
|
|
|
|
description="View Datasette instance",
|
|
|
|
takes_database=False,
|
|
|
|
takes_resource=False,
|
|
|
|
default=True,
|
2022-12-13 02:05:54 +00:00
|
|
|
),
|
|
|
|
Permission(
|
2023-08-29 16:32:34 +00:00
|
|
|
name="view-database",
|
|
|
|
abbr="vd",
|
|
|
|
description="View database",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=False,
|
|
|
|
default=True,
|
|
|
|
implies_can_view=True,
|
2022-12-13 02:05:54 +00:00
|
|
|
),
|
|
|
|
Permission(
|
2023-08-29 16:32:34 +00:00
|
|
|
name="view-database-download",
|
|
|
|
abbr="vdd",
|
|
|
|
description="Download database file",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=False,
|
|
|
|
default=True,
|
2022-12-13 02:05:54 +00:00
|
|
|
),
|
|
|
|
Permission(
|
2023-08-29 16:32:34 +00:00
|
|
|
name="view-table",
|
|
|
|
abbr="vt",
|
|
|
|
description="View table",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=True,
|
|
|
|
default=True,
|
|
|
|
implies_can_view=True,
|
|
|
|
),
|
|
|
|
Permission(
|
|
|
|
name="view-query",
|
|
|
|
abbr="vq",
|
|
|
|
description="View named query results",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=True,
|
|
|
|
default=True,
|
|
|
|
implies_can_view=True,
|
|
|
|
),
|
|
|
|
Permission(
|
|
|
|
name="execute-sql",
|
|
|
|
abbr="es",
|
|
|
|
description="Execute read-only SQL queries",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=False,
|
|
|
|
default=True,
|
2023-08-31 22:46:18 +00:00
|
|
|
implies_can_view=True,
|
2023-08-29 16:32:34 +00:00
|
|
|
),
|
|
|
|
Permission(
|
|
|
|
name="permissions-debug",
|
|
|
|
abbr="pd",
|
|
|
|
description="Access permission debug tool",
|
|
|
|
takes_database=False,
|
|
|
|
takes_resource=False,
|
|
|
|
default=False,
|
|
|
|
),
|
|
|
|
Permission(
|
|
|
|
name="debug-menu",
|
|
|
|
abbr="dm",
|
|
|
|
description="View debug menu items",
|
|
|
|
takes_database=False,
|
|
|
|
takes_resource=False,
|
|
|
|
default=False,
|
|
|
|
),
|
|
|
|
Permission(
|
|
|
|
name="insert-row",
|
|
|
|
abbr="ir",
|
|
|
|
description="Insert rows",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=True,
|
|
|
|
default=False,
|
|
|
|
),
|
|
|
|
Permission(
|
|
|
|
name="delete-row",
|
|
|
|
abbr="dr",
|
|
|
|
description="Delete rows",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=True,
|
|
|
|
default=False,
|
|
|
|
),
|
|
|
|
Permission(
|
|
|
|
name="update-row",
|
|
|
|
abbr="ur",
|
|
|
|
description="Update rows",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=True,
|
|
|
|
default=False,
|
|
|
|
),
|
|
|
|
Permission(
|
|
|
|
name="create-table",
|
|
|
|
abbr="ct",
|
|
|
|
description="Create tables",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=False,
|
|
|
|
default=False,
|
|
|
|
),
|
2024-02-08 20:21:13 +00:00
|
|
|
Permission(
|
|
|
|
name="alter-table",
|
|
|
|
abbr="at",
|
|
|
|
description="Alter tables",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=True,
|
|
|
|
default=False,
|
|
|
|
),
|
2023-08-29 16:32:34 +00:00
|
|
|
Permission(
|
|
|
|
name="drop-table",
|
|
|
|
abbr="dt",
|
|
|
|
description="Drop tables",
|
|
|
|
takes_database=True,
|
|
|
|
takes_resource=True,
|
|
|
|
default=False,
|
2022-12-13 02:05:54 +00:00
|
|
|
),
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2022-11-04 00:12:23 +00:00
|
|
|
@hookimpl(tryfirst=True, specname="permission_allowed")
|
|
|
|
def permission_allowed_default(datasette, actor, action, resource):
|
2020-06-18 23:22:33 +00:00
|
|
|
async def inner():
|
2022-12-13 02:05:54 +00:00
|
|
|
# id=root gets some special permissions:
|
2022-10-30 23:16:00 +00:00
|
|
|
if action in (
|
|
|
|
"permissions-debug",
|
|
|
|
"debug-menu",
|
|
|
|
"insert-row",
|
2022-11-15 05:57:28 +00:00
|
|
|
"create-table",
|
2024-02-08 20:21:13 +00:00
|
|
|
"alter-table",
|
2022-10-30 23:16:00 +00:00
|
|
|
"drop-table",
|
|
|
|
"delete-row",
|
2022-11-29 18:06:19 +00:00
|
|
|
"update-row",
|
2022-10-30 23:16:00 +00:00
|
|
|
):
|
2020-06-18 23:22:33 +00:00
|
|
|
if actor and actor.get("id") == "root":
|
|
|
|
return True
|
2022-12-13 02:05:54 +00:00
|
|
|
|
2024-02-16 18:05:18 +00:00
|
|
|
# Resolve view permissions in allow blocks in configuration
|
2022-12-13 02:05:54 +00:00
|
|
|
if action in (
|
|
|
|
"view-instance",
|
|
|
|
"view-database",
|
|
|
|
"view-table",
|
|
|
|
"view-query",
|
|
|
|
"execute-sql",
|
|
|
|
):
|
2023-10-12 16:16:37 +00:00
|
|
|
result = await _resolve_config_view_permissions(
|
2022-12-13 02:05:54 +00:00
|
|
|
datasette, actor, action, resource
|
|
|
|
)
|
|
|
|
if result is not None:
|
|
|
|
return result
|
|
|
|
|
2024-02-16 18:05:18 +00:00
|
|
|
# Resolve custom permissions: blocks in configuration
|
2023-10-12 16:16:37 +00:00
|
|
|
result = await _resolve_config_permissions_blocks(
|
2022-12-13 02:05:54 +00:00
|
|
|
datasette, actor, action, resource
|
|
|
|
)
|
2023-01-05 00:51:11 +00:00
|
|
|
if result is not None:
|
|
|
|
return result
|
|
|
|
|
|
|
|
# --setting default_allow_sql
|
|
|
|
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
|
|
|
|
return False
|
2020-06-18 23:22:33 +00:00
|
|
|
|
|
|
|
return inner
|
2022-10-26 02:18:41 +00:00
|
|
|
|
|
|
|
|
2023-10-12 16:16:37 +00:00
|
|
|
async def _resolve_config_permissions_blocks(datasette, actor, action, resource):
|
2022-12-13 02:40:45 +00:00
|
|
|
# Check custom permissions: blocks
|
2023-10-12 16:16:37 +00:00
|
|
|
config = datasette.config or {}
|
|
|
|
root_block = (config.get("permissions", None) or {}).get(action)
|
2022-12-13 02:40:45 +00:00
|
|
|
if root_block:
|
|
|
|
root_result = actor_matches_allow(actor, root_block)
|
|
|
|
if root_result is not None:
|
|
|
|
return root_result
|
|
|
|
# Now try database-specific blocks
|
|
|
|
if not resource:
|
|
|
|
return None
|
|
|
|
if isinstance(resource, str):
|
|
|
|
database = resource
|
|
|
|
else:
|
|
|
|
database = resource[0]
|
|
|
|
database_block = (
|
2023-10-12 16:16:37 +00:00
|
|
|
(config.get("databases", {}).get(database, {}).get("permissions", None)) or {}
|
2022-12-13 02:40:45 +00:00
|
|
|
).get(action)
|
|
|
|
if database_block:
|
|
|
|
database_result = actor_matches_allow(actor, database_block)
|
|
|
|
if database_result is not None:
|
|
|
|
return database_result
|
|
|
|
# Finally try table/query specific blocks
|
|
|
|
if not isinstance(resource, tuple):
|
|
|
|
return None
|
|
|
|
database, table_or_query = resource
|
|
|
|
table_block = (
|
|
|
|
(
|
2023-10-12 16:16:37 +00:00
|
|
|
config.get("databases", {})
|
2022-12-13 02:40:45 +00:00
|
|
|
.get(database, {})
|
|
|
|
.get("tables", {})
|
|
|
|
.get(table_or_query, {})
|
|
|
|
.get("permissions", None)
|
|
|
|
)
|
|
|
|
or {}
|
|
|
|
).get(action)
|
|
|
|
if table_block:
|
|
|
|
table_result = actor_matches_allow(actor, table_block)
|
|
|
|
if table_result is not None:
|
|
|
|
return table_result
|
|
|
|
# Finally the canned queries
|
|
|
|
query_block = (
|
|
|
|
(
|
2023-10-12 16:16:37 +00:00
|
|
|
config.get("databases", {})
|
2022-12-13 02:40:45 +00:00
|
|
|
.get(database, {})
|
|
|
|
.get("queries", {})
|
|
|
|
.get(table_or_query, {})
|
|
|
|
.get("permissions", None)
|
|
|
|
)
|
|
|
|
or {}
|
|
|
|
).get(action)
|
|
|
|
if query_block:
|
|
|
|
query_result = actor_matches_allow(actor, query_block)
|
|
|
|
if query_result is not None:
|
|
|
|
return query_result
|
2022-12-13 02:05:54 +00:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
2023-10-12 16:16:37 +00:00
|
|
|
async def _resolve_config_view_permissions(datasette, actor, action, resource):
|
|
|
|
config = datasette.config or {}
|
2022-12-13 02:05:54 +00:00
|
|
|
if action == "view-instance":
|
2023-10-12 16:16:37 +00:00
|
|
|
allow = config.get("allow")
|
2022-12-13 02:05:54 +00:00
|
|
|
if allow is not None:
|
|
|
|
return actor_matches_allow(actor, allow)
|
|
|
|
elif action == "view-database":
|
2023-10-12 16:16:37 +00:00
|
|
|
database_allow = ((config.get("databases") or {}).get(resource) or {}).get(
|
|
|
|
"allow"
|
|
|
|
)
|
2022-12-13 02:05:54 +00:00
|
|
|
if database_allow is None:
|
|
|
|
return None
|
|
|
|
return actor_matches_allow(actor, database_allow)
|
|
|
|
elif action == "view-table":
|
|
|
|
database, table = resource
|
2023-10-12 16:16:37 +00:00
|
|
|
tables = ((config.get("databases") or {}).get(database) or {}).get(
|
|
|
|
"tables"
|
|
|
|
) or {}
|
2022-12-13 02:05:54 +00:00
|
|
|
table_allow = (tables.get(table) or {}).get("allow")
|
|
|
|
if table_allow is None:
|
|
|
|
return None
|
|
|
|
return actor_matches_allow(actor, table_allow)
|
|
|
|
elif action == "view-query":
|
2023-10-12 16:16:37 +00:00
|
|
|
# Check if this query has a "allow" block in config
|
2022-12-13 02:05:54 +00:00
|
|
|
database, query_name = resource
|
|
|
|
query = await datasette.get_canned_query(database, query_name, actor)
|
|
|
|
assert query is not None
|
|
|
|
allow = query.get("allow")
|
|
|
|
if allow is None:
|
|
|
|
return None
|
|
|
|
return actor_matches_allow(actor, allow)
|
|
|
|
elif action == "execute-sql":
|
|
|
|
# Use allow_sql block from database block, or from top-level
|
2023-10-12 16:16:37 +00:00
|
|
|
database_allow_sql = ((config.get("databases") or {}).get(resource) or {}).get(
|
|
|
|
"allow_sql"
|
|
|
|
)
|
2022-12-13 02:05:54 +00:00
|
|
|
if database_allow_sql is None:
|
2023-10-12 16:16:37 +00:00
|
|
|
database_allow_sql = config.get("allow_sql")
|
2022-12-13 02:05:54 +00:00
|
|
|
if database_allow_sql is None:
|
|
|
|
return None
|
|
|
|
return actor_matches_allow(actor, database_allow_sql)
|
|
|
|
|
|
|
|
|
2023-08-29 16:32:34 +00:00
|
|
|
def restrictions_allow_action(
|
|
|
|
datasette: "Datasette",
|
|
|
|
restrictions: dict,
|
|
|
|
action: str,
|
|
|
|
resource: Union[str, Tuple[str, str]],
|
|
|
|
):
|
|
|
|
"Do these restrictions allow the requested action against the requested resource?"
|
|
|
|
if action == "view-instance":
|
|
|
|
# Special case for view-instance: it's allowed if the restrictions include any
|
|
|
|
# permissions that have the implies_can_view=True flag set
|
|
|
|
all_rules = restrictions.get("a") or []
|
|
|
|
for database_rules in (restrictions.get("d") or {}).values():
|
|
|
|
all_rules += database_rules
|
|
|
|
for database_resource_rules in (restrictions.get("r") or {}).values():
|
|
|
|
for resource_rules in database_resource_rules.values():
|
|
|
|
all_rules += resource_rules
|
|
|
|
permissions = [datasette.get_permission(action) for action in all_rules]
|
|
|
|
if any(p for p in permissions if p.implies_can_view):
|
|
|
|
return True
|
|
|
|
|
|
|
|
if action == "view-database":
|
|
|
|
# Special case for view-database: it's allowed if the restrictions include any
|
|
|
|
# permissions that have the implies_can_view=True flag set AND takes_database
|
|
|
|
all_rules = restrictions.get("a") or []
|
|
|
|
database_rules = list((restrictions.get("d") or {}).get(resource) or [])
|
|
|
|
all_rules += database_rules
|
|
|
|
resource_rules = ((restrictions.get("r") or {}).get(resource) or {}).values()
|
|
|
|
for resource_rules in (restrictions.get("r") or {}).values():
|
|
|
|
for table_rules in resource_rules.values():
|
|
|
|
all_rules += table_rules
|
|
|
|
permissions = [datasette.get_permission(action) for action in all_rules]
|
|
|
|
if any(p for p in permissions if p.implies_can_view and p.takes_database):
|
|
|
|
return True
|
2022-12-14 20:04:23 +00:00
|
|
|
|
|
|
|
# Does this action have an abbreviation?
|
|
|
|
to_check = {action}
|
|
|
|
permission = datasette.permissions.get(action)
|
|
|
|
if permission and permission.abbr:
|
|
|
|
to_check.add(permission.abbr)
|
|
|
|
|
2023-08-29 16:32:34 +00:00
|
|
|
# If restrictions is defined then we use those to further restrict the actor
|
2022-11-04 00:12:23 +00:00
|
|
|
# Crucially, we only use this to say NO (return False) - we never
|
|
|
|
# use it to return YES (True) because that might over-ride other
|
|
|
|
# restrictions placed on this actor
|
2023-08-29 16:32:34 +00:00
|
|
|
all_allowed = restrictions.get("a")
|
2022-11-04 00:12:23 +00:00
|
|
|
if all_allowed is not None:
|
|
|
|
assert isinstance(all_allowed, list)
|
2022-12-14 20:04:23 +00:00
|
|
|
if to_check.intersection(all_allowed):
|
2023-08-29 16:32:34 +00:00
|
|
|
return True
|
2022-11-04 00:12:23 +00:00
|
|
|
# How about for the current database?
|
2023-08-29 16:32:34 +00:00
|
|
|
if resource:
|
|
|
|
if isinstance(resource, str):
|
|
|
|
database_name = resource
|
|
|
|
else:
|
|
|
|
database_name = resource[0]
|
|
|
|
database_allowed = restrictions.get("d", {}).get(database_name)
|
2022-11-04 00:12:23 +00:00
|
|
|
if database_allowed is not None:
|
|
|
|
assert isinstance(database_allowed, list)
|
2022-12-14 20:04:23 +00:00
|
|
|
if to_check.intersection(database_allowed):
|
2023-08-29 16:32:34 +00:00
|
|
|
return True
|
2022-11-04 00:12:23 +00:00
|
|
|
# Or the current table? That's any time the resource is (database, table)
|
2022-12-08 01:12:15 +00:00
|
|
|
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
|
2022-11-04 00:12:23 +00:00
|
|
|
database, table = resource
|
2023-08-29 16:32:34 +00:00
|
|
|
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
|
2022-11-04 00:12:23 +00:00
|
|
|
# TODO: What should this do for canned queries?
|
|
|
|
if table_allowed is not None:
|
|
|
|
assert isinstance(table_allowed, list)
|
2022-12-14 20:04:23 +00:00
|
|
|
if to_check.intersection(table_allowed):
|
2023-08-29 16:32:34 +00:00
|
|
|
return True
|
|
|
|
|
2022-11-04 00:12:23 +00:00
|
|
|
# This action is not specifically allowed, so reject it
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
2023-08-29 16:32:34 +00:00
|
|
|
@hookimpl(specname="permission_allowed")
|
|
|
|
def permission_allowed_actor_restrictions(datasette, actor, action, resource):
|
|
|
|
if actor is None:
|
|
|
|
return None
|
|
|
|
if "_r" not in actor:
|
|
|
|
# No restrictions, so we have no opinion
|
|
|
|
return None
|
|
|
|
_r = actor.get("_r")
|
|
|
|
if restrictions_allow_action(datasette, _r, action, resource):
|
|
|
|
# Return None because we do not have an opinion here
|
|
|
|
return None
|
|
|
|
else:
|
|
|
|
# Block this permission check
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
2022-10-26 02:18:41 +00:00
|
|
|
@hookimpl
|
|
|
|
def actor_from_request(datasette, request):
|
|
|
|
prefix = "dstok_"
|
2022-10-26 02:55:47 +00:00
|
|
|
if not datasette.setting("allow_signed_tokens"):
|
|
|
|
return None
|
2022-10-26 21:13:31 +00:00
|
|
|
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
2022-10-26 02:18:41 +00:00
|
|
|
authorization = request.headers.get("authorization")
|
|
|
|
if not authorization:
|
|
|
|
return None
|
|
|
|
if not authorization.startswith("Bearer "):
|
|
|
|
return None
|
|
|
|
token = authorization[len("Bearer ") :]
|
|
|
|
if not token.startswith(prefix):
|
|
|
|
return None
|
|
|
|
token = token[len(prefix) :]
|
|
|
|
try:
|
|
|
|
decoded = datasette.unsign(token, namespace="token")
|
|
|
|
except itsdangerous.BadSignature:
|
|
|
|
return None
|
2022-10-26 21:13:31 +00:00
|
|
|
if "t" not in decoded:
|
|
|
|
# Missing timestamp
|
|
|
|
return None
|
|
|
|
created = decoded["t"]
|
|
|
|
if not isinstance(created, int):
|
|
|
|
# Invalid timestamp
|
|
|
|
return None
|
|
|
|
duration = decoded.get("d")
|
|
|
|
if duration is not None and not isinstance(duration, int):
|
|
|
|
# Invalid duration
|
|
|
|
return None
|
|
|
|
if (duration is None and max_signed_tokens_ttl) or (
|
|
|
|
duration is not None
|
|
|
|
and max_signed_tokens_ttl
|
|
|
|
and duration > max_signed_tokens_ttl
|
|
|
|
):
|
|
|
|
duration = max_signed_tokens_ttl
|
|
|
|
if duration:
|
|
|
|
if time.time() - created > duration:
|
|
|
|
# Expired
|
2022-10-26 02:18:41 +00:00
|
|
|
return None
|
2022-10-26 21:13:31 +00:00
|
|
|
actor = {"id": decoded["a"], "token": "dstok"}
|
2022-12-08 01:12:15 +00:00
|
|
|
if "_r" in decoded:
|
|
|
|
actor["_r"] = decoded["_r"]
|
2022-10-26 21:13:31 +00:00
|
|
|
if duration:
|
|
|
|
actor["token_expires"] = created + duration
|
|
|
|
return actor
|
2022-10-26 04:26:12 +00:00
|
|
|
|
|
|
|
|
2022-10-30 20:09:55 +00:00
|
|
|
@hookimpl
|
|
|
|
def skip_csrf(scope):
|
|
|
|
# Skip CSRF check for requests with content-type: application/json
|
|
|
|
if scope["type"] == "http":
|
|
|
|
headers = scope.get("headers") or {}
|
|
|
|
if dict(headers).get(b"content-type") == b"application/json":
|
|
|
|
return True
|