mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
New allowed_resources_sql plugin hook and debug tools (#2505)
* allowed_resources_sql plugin hook and infrastructure * New methods for checking permissions with the new system * New /-/allowed and /-/check and /-/rules special endpoints Still needs to be integrated more deeply into Datasette, especially for listing visible tables. Refs: #2502 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
85da8474d4
commit
27084caa04
20 changed files with 3381 additions and 27 deletions
160
datasette/app.py
160
datasette/app.py
|
|
@ -49,6 +49,9 @@ from .views.special import (
|
||||||
AllowDebugView,
|
AllowDebugView,
|
||||||
PermissionsDebugView,
|
PermissionsDebugView,
|
||||||
MessagesDebugView,
|
MessagesDebugView,
|
||||||
|
AllowedResourcesView,
|
||||||
|
PermissionRulesView,
|
||||||
|
PermissionCheckView,
|
||||||
)
|
)
|
||||||
from .views.table import (
|
from .views.table import (
|
||||||
TableInsertView,
|
TableInsertView,
|
||||||
|
|
@ -111,6 +114,8 @@ from .tracer import AsgiTracer
|
||||||
from .plugins import pm, DEFAULT_PLUGINS, get_plugins
|
from .plugins import pm, DEFAULT_PLUGINS, get_plugins
|
||||||
from .version import __version__
|
from .version import __version__
|
||||||
|
|
||||||
|
from .utils.permissions import build_rules_union, PluginSQL
|
||||||
|
|
||||||
app_root = Path(__file__).parent.parent
|
app_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
# https://github.com/simonw/datasette/issues/283#issuecomment-781591015
|
# https://github.com/simonw/datasette/issues/283#issuecomment-781591015
|
||||||
|
|
@ -1030,6 +1035,149 @@ class Datasette:
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def allowed_resources_sql(
|
||||||
|
self, actor: dict | None, action: str
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
"""Combine permission_resources_sql PluginSQL blocks into a UNION query.
|
||||||
|
|
||||||
|
Returns a (sql, params) tuple suitable for execution against SQLite.
|
||||||
|
"""
|
||||||
|
plugin_blocks: List[PluginSQL] = []
|
||||||
|
for block in pm.hook.permission_resources_sql(
|
||||||
|
datasette=self,
|
||||||
|
actor=actor,
|
||||||
|
action=action,
|
||||||
|
):
|
||||||
|
block = await await_me_maybe(block)
|
||||||
|
if block is None:
|
||||||
|
continue
|
||||||
|
if isinstance(block, (list, tuple)):
|
||||||
|
candidates = block
|
||||||
|
else:
|
||||||
|
candidates = [block]
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(candidate, PluginSQL):
|
||||||
|
continue
|
||||||
|
plugin_blocks.append(candidate)
|
||||||
|
|
||||||
|
actor_id = actor.get("id") if actor else None
|
||||||
|
sql, params = build_rules_union(
|
||||||
|
actor=str(actor_id) if actor_id is not None else "",
|
||||||
|
plugins=plugin_blocks,
|
||||||
|
)
|
||||||
|
return sql, params
|
||||||
|
|
||||||
|
async def permission_allowed_2(
|
||||||
|
self, actor, action, resource=None, *, default=DEFAULT_NOT_SET
|
||||||
|
):
|
||||||
|
"""Permission check backed by permission_resources_sql rules."""
|
||||||
|
|
||||||
|
if default is DEFAULT_NOT_SET and action in self.permissions:
|
||||||
|
default = self.permissions[action].default
|
||||||
|
|
||||||
|
if isinstance(actor, dict) or actor is None:
|
||||||
|
actor_dict = actor
|
||||||
|
else:
|
||||||
|
actor_dict = {"id": actor}
|
||||||
|
actor_id = actor_dict.get("id") if actor_dict else None
|
||||||
|
|
||||||
|
candidate_parent = None
|
||||||
|
candidate_child = None
|
||||||
|
if isinstance(resource, str):
|
||||||
|
candidate_parent = resource
|
||||||
|
elif isinstance(resource, (tuple, list)) and len(resource) == 2:
|
||||||
|
candidate_parent, candidate_child = resource
|
||||||
|
elif resource is not None:
|
||||||
|
raise TypeError("resource must be None, str, or (parent, child) tuple")
|
||||||
|
|
||||||
|
union_sql, union_params = await self.allowed_resources_sql(actor_dict, action)
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
WITH rules AS (
|
||||||
|
{union_sql}
|
||||||
|
),
|
||||||
|
candidate AS (
|
||||||
|
SELECT :cand_parent AS parent, :cand_child AS child
|
||||||
|
),
|
||||||
|
matched AS (
|
||||||
|
SELECT
|
||||||
|
r.allow,
|
||||||
|
r.reason,
|
||||||
|
r.source_plugin,
|
||||||
|
CASE
|
||||||
|
WHEN r.child IS NOT NULL THEN 2
|
||||||
|
WHEN r.parent IS NOT NULL THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END AS depth
|
||||||
|
FROM rules r
|
||||||
|
JOIN candidate c
|
||||||
|
ON (r.parent IS NULL OR r.parent = c.parent)
|
||||||
|
AND (r.child IS NULL OR r.child = c.child)
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
ORDER BY
|
||||||
|
depth DESC,
|
||||||
|
CASE WHEN allow = 0 THEN 0 ELSE 1 END,
|
||||||
|
source_plugin
|
||||||
|
) AS rn
|
||||||
|
FROM matched
|
||||||
|
),
|
||||||
|
winner AS (
|
||||||
|
SELECT allow, reason, source_plugin, depth
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn = 1
|
||||||
|
)
|
||||||
|
SELECT allow, reason, source_plugin, depth FROM winner
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = {
|
||||||
|
**union_params,
|
||||||
|
"cand_parent": candidate_parent,
|
||||||
|
"cand_child": candidate_child,
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = await self.get_internal_database().execute(query, params)
|
||||||
|
row = rows.first()
|
||||||
|
|
||||||
|
reason = None
|
||||||
|
source_plugin = None
|
||||||
|
depth = None
|
||||||
|
used_default = False
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
result = default
|
||||||
|
used_default = True
|
||||||
|
else:
|
||||||
|
allow = row["allow"]
|
||||||
|
reason = row["reason"]
|
||||||
|
source_plugin = row["source_plugin"]
|
||||||
|
depth = row["depth"]
|
||||||
|
if allow is None:
|
||||||
|
result = default
|
||||||
|
used_default = True
|
||||||
|
else:
|
||||||
|
result = bool(allow)
|
||||||
|
|
||||||
|
self._permission_checks.append(
|
||||||
|
{
|
||||||
|
"when": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
"actor": actor,
|
||||||
|
"action": action,
|
||||||
|
"resource": resource,
|
||||||
|
"used_default": used_default,
|
||||||
|
"result": result,
|
||||||
|
"reason": reason,
|
||||||
|
"source_plugin": source_plugin,
|
||||||
|
"depth": depth,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
async def ensure_permissions(
|
async def ensure_permissions(
|
||||||
self,
|
self,
|
||||||
actor: dict,
|
actor: dict,
|
||||||
|
|
@ -1586,6 +1734,18 @@ class Datasette:
|
||||||
PermissionsDebugView.as_view(self),
|
PermissionsDebugView.as_view(self),
|
||||||
r"/-/permissions$",
|
r"/-/permissions$",
|
||||||
)
|
)
|
||||||
|
add_route(
|
||||||
|
AllowedResourcesView.as_view(self),
|
||||||
|
r"/-/allowed(\.(?P<format>json))?$",
|
||||||
|
)
|
||||||
|
add_route(
|
||||||
|
PermissionRulesView.as_view(self),
|
||||||
|
r"/-/rules(\.(?P<format>json))?$",
|
||||||
|
)
|
||||||
|
add_route(
|
||||||
|
PermissionCheckView.as_view(self),
|
||||||
|
r"/-/check(\.(?P<format>json))?$",
|
||||||
|
)
|
||||||
add_route(
|
add_route(
|
||||||
MessagesDebugView.as_view(self),
|
MessagesDebugView.as_view(self),
|
||||||
r"/-/messages$",
|
r"/-/messages$",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from datasette import hookimpl, Permission
|
from datasette import hookimpl, Permission
|
||||||
|
from datasette.utils.permissions import PluginSQL
|
||||||
from datasette.utils import actor_matches_allow
|
from datasette.utils import actor_matches_allow
|
||||||
import itsdangerous
|
import itsdangerous
|
||||||
import time
|
import time
|
||||||
from typing import Union, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -172,6 +172,163 @@ def permission_allowed_default(datasette, actor, action, resource):
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
async def permission_resources_sql(datasette, actor, action):
|
||||||
|
rules: list[PluginSQL] = []
|
||||||
|
|
||||||
|
config_rules = await _config_permission_rules(datasette, actor, action)
|
||||||
|
rules.extend(config_rules)
|
||||||
|
|
||||||
|
default_allow_actions = {
|
||||||
|
"view-instance",
|
||||||
|
"view-database",
|
||||||
|
"view-table",
|
||||||
|
"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(
|
||||||
|
PluginSQL(
|
||||||
|
source="default_permissions",
|
||||||
|
sql=sql,
|
||||||
|
params={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rules:
|
||||||
|
return None
|
||||||
|
if len(rules) == 1:
|
||||||
|
return rules[0]
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]:
|
||||||
|
config = datasette.config or {}
|
||||||
|
|
||||||
|
if actor is None:
|
||||||
|
actor_dict: dict | None = None
|
||||||
|
elif isinstance(actor, dict):
|
||||||
|
actor_dict = actor
|
||||||
|
else:
|
||||||
|
actor_lookup = await datasette.actors_from_ids([actor])
|
||||||
|
actor_dict = actor_lookup.get(actor) or {"id": actor}
|
||||||
|
|
||||||
|
def evaluate(allow_block):
|
||||||
|
if allow_block is None:
|
||||||
|
return None
|
||||||
|
return actor_matches_allow(actor_dict, allow_block)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
def add_row(parent, child, result, scope):
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
parent,
|
||||||
|
child,
|
||||||
|
bool(result),
|
||||||
|
f"config {'allow' if result else 'deny'} {scope}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
root_perm = (config.get("permissions") or {}).get(action)
|
||||||
|
add_row(None, None, evaluate(root_perm), f"permissions for {action}")
|
||||||
|
|
||||||
|
for db_name, db_config in (config.get("databases") or {}).items():
|
||||||
|
db_perm = (db_config.get("permissions") or {}).get(action)
|
||||||
|
add_row(
|
||||||
|
db_name, None, evaluate(db_perm), f"permissions for {action} on {db_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for table_name, table_config in (db_config.get("tables") or {}).items():
|
||||||
|
table_perm = (table_config.get("permissions") or {}).get(action)
|
||||||
|
add_row(
|
||||||
|
db_name,
|
||||||
|
table_name,
|
||||||
|
evaluate(table_perm),
|
||||||
|
f"permissions for {action} on {db_name}/{table_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "view-table":
|
||||||
|
table_allow = (table_config or {}).get("allow")
|
||||||
|
add_row(
|
||||||
|
db_name,
|
||||||
|
table_name,
|
||||||
|
evaluate(table_allow),
|
||||||
|
f"allow for {action} on {db_name}/{table_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||||
|
query_perm = (query_config.get("permissions") or {}).get(action)
|
||||||
|
add_row(
|
||||||
|
db_name,
|
||||||
|
query_name,
|
||||||
|
evaluate(query_perm),
|
||||||
|
f"permissions for {action} on {db_name}/{query_name}",
|
||||||
|
)
|
||||||
|
if action == "view-query":
|
||||||
|
query_allow = (query_config or {}).get("allow")
|
||||||
|
add_row(
|
||||||
|
db_name,
|
||||||
|
query_name,
|
||||||
|
evaluate(query_allow),
|
||||||
|
f"allow for {action} on {db_name}/{query_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "view-database":
|
||||||
|
db_allow = db_config.get("allow")
|
||||||
|
add_row(
|
||||||
|
db_name, None, evaluate(db_allow), f"allow for {action} on {db_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "execute-sql":
|
||||||
|
db_allow_sql = db_config.get("allow_sql")
|
||||||
|
add_row(db_name, None, evaluate(db_allow_sql), f"allow_sql for {db_name}")
|
||||||
|
|
||||||
|
if action == "view-instance":
|
||||||
|
allow_block = config.get("allow")
|
||||||
|
add_row(None, None, evaluate(allow_block), "allow for view-instance")
|
||||||
|
|
||||||
|
if action == "view-table":
|
||||||
|
# Tables handled in loop
|
||||||
|
pass
|
||||||
|
|
||||||
|
if action == "view-query":
|
||||||
|
# Queries handled in loop
|
||||||
|
pass
|
||||||
|
|
||||||
|
if action == "execute-sql":
|
||||||
|
allow_sql = config.get("allow_sql")
|
||||||
|
add_row(None, None, evaluate(allow_sql), "allow_sql")
|
||||||
|
|
||||||
|
if action == "view-database":
|
||||||
|
# already handled per-database
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
params = {}
|
||||||
|
for idx, (parent, child, allow, reason) in enumerate(rows):
|
||||||
|
key = f"cfg_{idx}"
|
||||||
|
parts.append(
|
||||||
|
f"SELECT :{key}_parent AS parent, :{key}_child AS child, :{key}_allow AS allow, :{key}_reason AS reason"
|
||||||
|
)
|
||||||
|
params[f"{key}_parent"] = parent
|
||||||
|
params[f"{key}_child"] = child
|
||||||
|
params[f"{key}_allow"] = 1 if allow else 0
|
||||||
|
params[f"{key}_reason"] = reason
|
||||||
|
|
||||||
|
sql = "\nUNION ALL\n".join(parts)
|
||||||
|
print(sql, params)
|
||||||
|
return [PluginSQL(source="config_permissions", sql=sql, params=params)]
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_config_permissions_blocks(datasette, actor, action, resource):
|
async def _resolve_config_permissions_blocks(datasette, actor, action, resource):
|
||||||
# Check custom permissions: blocks
|
# Check custom permissions: blocks
|
||||||
config = datasette.config or {}
|
config = datasette.config or {}
|
||||||
|
|
@ -277,7 +434,7 @@ def restrictions_allow_action(
|
||||||
datasette: "Datasette",
|
datasette: "Datasette",
|
||||||
restrictions: dict,
|
restrictions: dict,
|
||||||
action: str,
|
action: str,
|
||||||
resource: Union[str, Tuple[str, str]],
|
resource: str | tuple[str, str],
|
||||||
):
|
):
|
||||||
"Do these restrictions allow the requested action against the requested resource?"
|
"Do these restrictions allow the requested action against the requested resource?"
|
||||||
if action == "view-instance":
|
if action == "view-instance":
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,18 @@ def permission_allowed(datasette, actor, action, resource):
|
||||||
"""Check if actor is allowed to perform this action - return True, False or None"""
|
"""Check if actor is allowed to perform this action - return True, False or None"""
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def permission_resources_sql(datasette, actor, action):
|
||||||
|
"""Return SQL query fragments for permission checks on resources.
|
||||||
|
|
||||||
|
Returns None, a PluginSQL object, or a list of PluginSQL objects.
|
||||||
|
Each PluginSQL contains SQL that should return rows with columns:
|
||||||
|
parent (str|None), child (str|None), allow (int), reason (str).
|
||||||
|
|
||||||
|
Used to efficiently check permissions across multiple resources at once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def canned_queries(datasette, database, actor):
|
def canned_queries(datasette, database, actor):
|
||||||
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
|
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
|
||||||
|
|
|
||||||
145
datasette/templates/_permission_ui_styles.html
Normal file
145
datasette/templates/_permission_ui_styles.html
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
<style>
|
||||||
|
.permission-form {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1.5em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.form-section label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.form-section input[type="text"],
|
||||||
|
.form-section select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 0.5em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.form-section input[type="text"]:focus,
|
||||||
|
.form-section select:focus {
|
||||||
|
outline: 2px solid #0066cc;
|
||||||
|
border-color: #0066cc;
|
||||||
|
}
|
||||||
|
.form-section small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
.submit-btn {
|
||||||
|
padding: 0.6em 1.5em;
|
||||||
|
font-size: 1em;
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:hover {
|
||||||
|
background-color: #0052a3;
|
||||||
|
}
|
||||||
|
.submit-btn:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.results-container {
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
|
.results-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.results-count {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.results-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.results-table th {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 0.75em;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 2px solid #ddd;
|
||||||
|
}
|
||||||
|
.results-table td {
|
||||||
|
padding: 0.75em;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.results-table tr:hover {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
.results-table tr.allow-row {
|
||||||
|
background-color: #f1f8f4;
|
||||||
|
}
|
||||||
|
.results-table tr.allow-row:hover {
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
}
|
||||||
|
.results-table tr.deny-row {
|
||||||
|
background-color: #fef5f5;
|
||||||
|
}
|
||||||
|
.results-table tr.deny-row:hover {
|
||||||
|
background-color: #ffebee;
|
||||||
|
}
|
||||||
|
.resource-path {
|
||||||
|
font-family: monospace;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
margin-top: 1.5em;
|
||||||
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.pagination a {
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.pagination a:hover {
|
||||||
|
background-color: #0052a3;
|
||||||
|
}
|
||||||
|
.pagination span {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.no-results {
|
||||||
|
padding: 2em;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
padding: 1em;
|
||||||
|
background-color: #ffebee;
|
||||||
|
border: 2px solid #f44336;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
padding: 2em;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
294
datasette/templates/debug_allowed.html
Normal file
294
datasette/templates/debug_allowed.html
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Allowed Resources{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||||
|
{% include "_permission_ui_styles.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1>Allowed Resources</h1>
|
||||||
|
|
||||||
|
<p>Use this tool to check which resources the current actor is allowed to access for a given permission action. It queries the <code>/-/allowed.json</code> API endpoint.</p>
|
||||||
|
|
||||||
|
{% if request.actor %}
|
||||||
|
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||||
|
{% else %}
|
||||||
|
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="permission-form">
|
||||||
|
<form id="allowed-form">
|
||||||
|
<div class="form-section">
|
||||||
|
<label for="action">Action (permission name):</label>
|
||||||
|
<select id="action" name="action" required>
|
||||||
|
<option value="">Select an action...</option>
|
||||||
|
{% for permission_name in supported_actions %}
|
||||||
|
<option value="{{ permission_name }}">{{ permission_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small>Only certain actions are supported by this endpoint</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label for="parent">Filter by parent (optional):</label>
|
||||||
|
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||||
|
<small>Filter results to a specific parent resource</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label for="child">Filter by child (optional):</label>
|
||||||
|
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||||
|
<small>Filter results to a specific child resource (requires parent)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label for="page_size">Page size:</label>
|
||||||
|
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||||
|
<small>Number of results per page (max 200)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="submit-btn" id="submit-btn">Check Allowed Resources</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="results-container" style="display: none;">
|
||||||
|
<div class="results-header">
|
||||||
|
<h2>Results</h2>
|
||||||
|
<div class="results-count" id="results-count"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="results-content"></div>
|
||||||
|
|
||||||
|
<div id="pagination" class="pagination"></div>
|
||||||
|
|
||||||
|
<details style="margin-top: 2em;">
|
||||||
|
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||||
|
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('allowed-form');
|
||||||
|
const resultsContainer = document.getElementById('results-container');
|
||||||
|
const resultsContent = document.getElementById('results-content');
|
||||||
|
const resultsCount = document.getElementById('results-count');
|
||||||
|
const pagination = document.getElementById('pagination');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
let currentData = null;
|
||||||
|
|
||||||
|
// Populate form from URL parameters on page load
|
||||||
|
function populateFormFromURL() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
const action = params.get('action');
|
||||||
|
if (action) {
|
||||||
|
document.getElementById('action').value = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = params.get('parent');
|
||||||
|
if (parent) {
|
||||||
|
document.getElementById('parent').value = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = params.get('child');
|
||||||
|
if (child) {
|
||||||
|
document.getElementById('child').value = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = params.get('page_size');
|
||||||
|
if (pageSize) {
|
||||||
|
document.getElementById('page_size').value = pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = params.get('page');
|
||||||
|
|
||||||
|
// If parameters are present, automatically fetch results
|
||||||
|
if (action) {
|
||||||
|
fetchResults(page ? parseInt(page) : 1, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update URL with current form values and page
|
||||||
|
function updateURL(page = 1) {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value) {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page > 1) {
|
||||||
|
params.set('page', page.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
|
||||||
|
window.history.pushState({}, '', newURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
updateURL(1);
|
||||||
|
await fetchResults(1, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle browser back/forward
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
populateFormFromURL();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate form on initial load
|
||||||
|
populateFormFromURL();
|
||||||
|
|
||||||
|
async function fetchResults(page = 1, updateHistory = true) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Loading...';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value && key !== 'page_size') {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = document.getElementById('page_size').value || '50';
|
||||||
|
params.append('page', page.toString());
|
||||||
|
params.append('page_size', pageSize);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
currentData = data;
|
||||||
|
displayResults(data);
|
||||||
|
} else {
|
||||||
|
displayError(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
displayError({ error: error.message });
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Check Allowed Resources';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayResults(data) {
|
||||||
|
resultsContainer.style.display = 'block';
|
||||||
|
|
||||||
|
// Update count
|
||||||
|
resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total resources (page ${data.page})`;
|
||||||
|
|
||||||
|
// Display results table
|
||||||
|
if (data.items.length === 0) {
|
||||||
|
resultsContent.innerHTML = '<div class="no-results">No allowed resources found for this action.</div>';
|
||||||
|
} else {
|
||||||
|
let html = '<table class="results-table">';
|
||||||
|
html += '<thead><tr>';
|
||||||
|
html += '<th>Resource Path</th>';
|
||||||
|
html += '<th>Parent</th>';
|
||||||
|
html += '<th>Child</th>';
|
||||||
|
html += '<th>Reason</th>';
|
||||||
|
html += '<th>Source Plugin</th>';
|
||||||
|
html += '</tr></thead>';
|
||||||
|
html += '<tbody>';
|
||||||
|
|
||||||
|
for (const item of data.items) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||||
|
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(item.reason || '—')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;
|
||||||
|
html += '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
resultsContent.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pagination
|
||||||
|
pagination.innerHTML = '';
|
||||||
|
if (data.previous_url || data.next_url) {
|
||||||
|
if (data.previous_url) {
|
||||||
|
const prevLink = document.createElement('a');
|
||||||
|
prevLink.href = '#';
|
||||||
|
prevLink.textContent = '← Previous';
|
||||||
|
prevLink.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateURL(data.page - 1);
|
||||||
|
fetchResults(data.page - 1, false);
|
||||||
|
});
|
||||||
|
pagination.appendChild(prevLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageInfo = document.createElement('span');
|
||||||
|
pageInfo.textContent = `Page ${data.page}`;
|
||||||
|
pagination.appendChild(pageInfo);
|
||||||
|
|
||||||
|
if (data.next_url) {
|
||||||
|
const nextLink = document.createElement('a');
|
||||||
|
nextLink.href = '#';
|
||||||
|
nextLink.textContent = 'Next →';
|
||||||
|
nextLink.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateURL(data.page + 1);
|
||||||
|
fetchResults(data.page + 1, false);
|
||||||
|
});
|
||||||
|
pagination.appendChild(nextLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update raw JSON
|
||||||
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
|
|
||||||
|
// Scroll to results
|
||||||
|
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayError(data) {
|
||||||
|
resultsContainer.style.display = 'block';
|
||||||
|
resultsCount.textContent = '';
|
||||||
|
pagination.innerHTML = '';
|
||||||
|
|
||||||
|
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||||
|
|
||||||
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
|
|
||||||
|
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (text === null || text === undefined) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable child input if parent is empty
|
||||||
|
const parentInput = document.getElementById('parent');
|
||||||
|
const childInput = document.getElementById('child');
|
||||||
|
|
||||||
|
childInput.addEventListener('focus', () => {
|
||||||
|
if (!parentInput.value) {
|
||||||
|
alert('Please specify a parent resource first before filtering by child resource.');
|
||||||
|
parentInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
350
datasette/templates/debug_check.html
Normal file
350
datasette/templates/debug_check.html
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Permission Check{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||||
|
<style>
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.form-section label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.form-section input[type="text"],
|
||||||
|
.form-section select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 0.5em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.form-section input[type="text"]:focus,
|
||||||
|
.form-section select:focus {
|
||||||
|
outline: 2px solid #0066cc;
|
||||||
|
border-color: #0066cc;
|
||||||
|
}
|
||||||
|
.form-section small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
#output {
|
||||||
|
margin-top: 2em;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
#output.allowed {
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
border: 2px solid #4caf50;
|
||||||
|
}
|
||||||
|
#output.denied {
|
||||||
|
background-color: #ffebee;
|
||||||
|
border: 2px solid #f44336;
|
||||||
|
}
|
||||||
|
#output h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
#output .result-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.3em 0.8em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
#output .allowed-badge {
|
||||||
|
background-color: #4caf50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
#output .denied-badge {
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.details-section {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
.details-section dt {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
.details-section dd {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
#submit-btn {
|
||||||
|
padding: 0.6em 1.5em;
|
||||||
|
font-size: 1em;
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#submit-btn:hover {
|
||||||
|
background-color: #0052a3;
|
||||||
|
}
|
||||||
|
#submit-btn:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1>Permission Check</h1>
|
||||||
|
|
||||||
|
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
|
||||||
|
|
||||||
|
{% if request.actor %}
|
||||||
|
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||||
|
{% else %}
|
||||||
|
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form id="check-form" class="core">
|
||||||
|
<div class="form-section">
|
||||||
|
<label for="action">Action (permission name):</label>
|
||||||
|
<select id="action" name="action" required>
|
||||||
|
<option value="">Select an action...</option>
|
||||||
|
{% for permission_name in sorted_permissions %}
|
||||||
|
<option value="{{ permission_name }}">{{ permission_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small>The permission action to check</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label for="parent">Parent resource (optional):</label>
|
||||||
|
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||||
|
<small>For database-level permissions, specify the database name</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label for="child">Child resource (optional):</label>
|
||||||
|
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||||
|
<small>For table-level permissions, specify the table name (requires parent)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="submit-btn">Check Permission</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="output" style="display: none;">
|
||||||
|
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||||
|
|
||||||
|
<dl class="details-section">
|
||||||
|
<dt>Action:</dt>
|
||||||
|
<dd id="result-action"></dd>
|
||||||
|
|
||||||
|
<dt>Resource Path:</dt>
|
||||||
|
<dd id="result-resource"></dd>
|
||||||
|
|
||||||
|
<dt>Actor ID:</dt>
|
||||||
|
<dd id="result-actor"></dd>
|
||||||
|
|
||||||
|
<div id="additional-details"></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<details style="margin-top: 1em;">
|
||||||
|
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||||
|
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('check-form');
|
||||||
|
const output = document.getElementById('output');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
|
||||||
|
// Populate form from URL parameters on page load
|
||||||
|
function populateFormFromURL() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
const action = params.get('action');
|
||||||
|
if (action) {
|
||||||
|
document.getElementById('action').value = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = params.get('parent');
|
||||||
|
if (parent) {
|
||||||
|
document.getElementById('parent').value = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = params.get('child');
|
||||||
|
if (child) {
|
||||||
|
document.getElementById('child').value = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If parameters are present, automatically submit the form
|
||||||
|
if (action) {
|
||||||
|
performCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update URL with current form values
|
||||||
|
function updateURL() {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value) {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
|
||||||
|
window.history.pushState({}, '', newURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performCheck() {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Checking...';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value) {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
displayResult(data);
|
||||||
|
} else {
|
||||||
|
displayError(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Check Permission';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
updateURL();
|
||||||
|
await performCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle browser back/forward
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
populateFormFromURL();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate form on initial load
|
||||||
|
populateFormFromURL();
|
||||||
|
|
||||||
|
function displayResult(data) {
|
||||||
|
output.style.display = 'block';
|
||||||
|
|
||||||
|
// Set badge and styling
|
||||||
|
const resultBadge = document.getElementById('result-badge');
|
||||||
|
if (data.allowed) {
|
||||||
|
output.className = 'allowed';
|
||||||
|
resultBadge.className = 'result-badge allowed-badge';
|
||||||
|
resultBadge.textContent = 'ALLOWED ✓';
|
||||||
|
} else {
|
||||||
|
output.className = 'denied';
|
||||||
|
resultBadge.className = 'result-badge denied-badge';
|
||||||
|
resultBadge.textContent = 'DENIED ✗';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic details
|
||||||
|
document.getElementById('result-action').textContent = data.action || 'N/A';
|
||||||
|
document.getElementById('result-resource').textContent = data.resource?.path || '/';
|
||||||
|
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
|
||||||
|
|
||||||
|
// Additional details
|
||||||
|
const additionalDetails = document.getElementById('additional-details');
|
||||||
|
additionalDetails.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.reason !== undefined) {
|
||||||
|
const dt = document.createElement('dt');
|
||||||
|
dt.textContent = 'Reason:';
|
||||||
|
const dd = document.createElement('dd');
|
||||||
|
dd.textContent = data.reason || 'N/A';
|
||||||
|
additionalDetails.appendChild(dt);
|
||||||
|
additionalDetails.appendChild(dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.source_plugin !== undefined) {
|
||||||
|
const dt = document.createElement('dt');
|
||||||
|
dt.textContent = 'Source Plugin:';
|
||||||
|
const dd = document.createElement('dd');
|
||||||
|
dd.textContent = data.source_plugin || 'N/A';
|
||||||
|
additionalDetails.appendChild(dt);
|
||||||
|
additionalDetails.appendChild(dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.used_default !== undefined) {
|
||||||
|
const dt = document.createElement('dt');
|
||||||
|
dt.textContent = 'Used Default:';
|
||||||
|
const dd = document.createElement('dd');
|
||||||
|
dd.textContent = data.used_default ? 'Yes' : 'No';
|
||||||
|
additionalDetails.appendChild(dt);
|
||||||
|
additionalDetails.appendChild(dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.depth !== undefined) {
|
||||||
|
const dt = document.createElement('dt');
|
||||||
|
dt.textContent = 'Depth:';
|
||||||
|
const dd = document.createElement('dd');
|
||||||
|
dd.textContent = data.depth;
|
||||||
|
additionalDetails.appendChild(dt);
|
||||||
|
additionalDetails.appendChild(dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw JSON
|
||||||
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
|
|
||||||
|
// Scroll to output
|
||||||
|
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayError(data) {
|
||||||
|
output.style.display = 'block';
|
||||||
|
output.className = 'denied';
|
||||||
|
|
||||||
|
const resultBadge = document.getElementById('result-badge');
|
||||||
|
resultBadge.className = 'result-badge denied-badge';
|
||||||
|
resultBadge.textContent = 'ERROR';
|
||||||
|
|
||||||
|
document.getElementById('result-action').textContent = 'N/A';
|
||||||
|
document.getElementById('result-resource').textContent = 'N/A';
|
||||||
|
document.getElementById('result-actor').textContent = 'N/A';
|
||||||
|
|
||||||
|
const additionalDetails = document.getElementById('additional-details');
|
||||||
|
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
||||||
|
|
||||||
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
|
|
||||||
|
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable child input if parent is empty
|
||||||
|
const parentInput = document.getElementById('parent');
|
||||||
|
const childInput = document.getElementById('child');
|
||||||
|
|
||||||
|
childInput.addEventListener('focus', () => {
|
||||||
|
if (!parentInput.value) {
|
||||||
|
alert('Please specify a parent resource first before adding a child resource.');
|
||||||
|
parentInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
268
datasette/templates/debug_rules.html
Normal file
268
datasette/templates/debug_rules.html
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Permission Rules{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||||
|
{% include "_permission_ui_styles.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1>Permission Rules</h1>
|
||||||
|
|
||||||
|
<p>Use this tool to view the permission rules that allow the current actor to access resources for a given permission action. It queries the <code>/-/rules.json</code> API endpoint.</p>
|
||||||
|
|
||||||
|
{% if request.actor %}
|
||||||
|
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||||
|
{% else %}
|
||||||
|
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="permission-form">
|
||||||
|
<form id="rules-form">
|
||||||
|
<div class="form-section">
|
||||||
|
<label for="action">Action (permission name):</label>
|
||||||
|
<select id="action" name="action" required>
|
||||||
|
<option value="">Select an action...</option>
|
||||||
|
{% for permission_name in sorted_permissions %}
|
||||||
|
<option value="{{ permission_name }}">{{ permission_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small>The permission action to check</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label for="page_size">Page size:</label>
|
||||||
|
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||||
|
<small>Number of results per page (max 200)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="submit-btn" id="submit-btn">View Permission Rules</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="results-container" style="display: none;">
|
||||||
|
<div class="results-header">
|
||||||
|
<h2>Results</h2>
|
||||||
|
<div class="results-count" id="results-count"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="results-content"></div>
|
||||||
|
|
||||||
|
<div id="pagination" class="pagination"></div>
|
||||||
|
|
||||||
|
<details style="margin-top: 2em;">
|
||||||
|
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||||
|
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('rules-form');
|
||||||
|
const resultsContainer = document.getElementById('results-container');
|
||||||
|
const resultsContent = document.getElementById('results-content');
|
||||||
|
const resultsCount = document.getElementById('results-count');
|
||||||
|
const pagination = document.getElementById('pagination');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
let currentData = null;
|
||||||
|
|
||||||
|
// Populate form from URL parameters on page load
|
||||||
|
function populateFormFromURL() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
const action = params.get('action');
|
||||||
|
if (action) {
|
||||||
|
document.getElementById('action').value = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = params.get('page_size');
|
||||||
|
if (pageSize) {
|
||||||
|
document.getElementById('page_size').value = pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = params.get('page');
|
||||||
|
|
||||||
|
// If parameters are present, automatically fetch results
|
||||||
|
if (action) {
|
||||||
|
fetchResults(page ? parseInt(page) : 1, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update URL with current form values and page
|
||||||
|
function updateURL(page = 1) {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value) {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page > 1) {
|
||||||
|
params.set('page', page.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
|
||||||
|
window.history.pushState({}, '', newURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
updateURL(1);
|
||||||
|
await fetchResults(1, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle browser back/forward
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
populateFormFromURL();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate form on initial load
|
||||||
|
populateFormFromURL();
|
||||||
|
|
||||||
|
async function fetchResults(page = 1, updateHistory = true) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Loading...';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value && key !== 'page_size') {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = document.getElementById('page_size').value || '50';
|
||||||
|
params.append('page', page.toString());
|
||||||
|
params.append('page_size', pageSize);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
currentData = data;
|
||||||
|
displayResults(data);
|
||||||
|
} else {
|
||||||
|
displayError(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
displayError({ error: error.message });
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'View Permission Rules';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayResults(data) {
|
||||||
|
resultsContainer.style.display = 'block';
|
||||||
|
|
||||||
|
// Update count
|
||||||
|
resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total rules (page ${data.page})`;
|
||||||
|
|
||||||
|
// Display results table
|
||||||
|
if (data.items.length === 0) {
|
||||||
|
resultsContent.innerHTML = '<div class="no-results">No permission rules found for this action.</div>';
|
||||||
|
} else {
|
||||||
|
let html = '<table class="results-table">';
|
||||||
|
html += '<thead><tr>';
|
||||||
|
html += '<th>Effect</th>';
|
||||||
|
html += '<th>Resource Path</th>';
|
||||||
|
html += '<th>Parent</th>';
|
||||||
|
html += '<th>Child</th>';
|
||||||
|
html += '<th>Reason</th>';
|
||||||
|
html += '<th>Source Plugin</th>';
|
||||||
|
html += '</tr></thead>';
|
||||||
|
html += '<tbody>';
|
||||||
|
|
||||||
|
for (const item of data.items) {
|
||||||
|
const rowClass = item.allow ? 'allow-row' : 'deny-row';
|
||||||
|
const effectBadge = item.allow
|
||||||
|
? '<span style="background: #4caf50; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;">ALLOW</span>'
|
||||||
|
: '<span style="background: #f44336; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;">DENY</span>';
|
||||||
|
|
||||||
|
html += `<tr class="${rowClass}">`;
|
||||||
|
html += `<td>${effectBadge}</td>`;
|
||||||
|
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||||
|
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(item.reason || '—')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;
|
||||||
|
html += '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
resultsContent.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pagination
|
||||||
|
pagination.innerHTML = '';
|
||||||
|
if (data.previous_url || data.next_url) {
|
||||||
|
if (data.previous_url) {
|
||||||
|
const prevLink = document.createElement('a');
|
||||||
|
prevLink.href = '#';
|
||||||
|
prevLink.textContent = '← Previous';
|
||||||
|
prevLink.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateURL(data.page - 1);
|
||||||
|
fetchResults(data.page - 1, false);
|
||||||
|
});
|
||||||
|
pagination.appendChild(prevLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageInfo = document.createElement('span');
|
||||||
|
pageInfo.textContent = `Page ${data.page}`;
|
||||||
|
pagination.appendChild(pageInfo);
|
||||||
|
|
||||||
|
if (data.next_url) {
|
||||||
|
const nextLink = document.createElement('a');
|
||||||
|
nextLink.href = '#';
|
||||||
|
nextLink.textContent = 'Next →';
|
||||||
|
nextLink.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateURL(data.page + 1);
|
||||||
|
fetchResults(data.page + 1, false);
|
||||||
|
});
|
||||||
|
pagination.appendChild(nextLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update raw JSON
|
||||||
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
|
|
||||||
|
// Scroll to results
|
||||||
|
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayError(data) {
|
||||||
|
resultsContainer.style.display = 'block';
|
||||||
|
resultsCount.textContent = '';
|
||||||
|
pagination.innerHTML = '';
|
||||||
|
|
||||||
|
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||||
|
|
||||||
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
|
|
||||||
|
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (text === null || text === undefined) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
244
datasette/utils/permissions.py
Normal file
244
datasette/utils/permissions.py
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
# perm_utils.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Plugin interface & utilities
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PluginSQL:
|
||||||
|
"""
|
||||||
|
A plugin contributes SQL that yields:
|
||||||
|
parent TEXT NULL,
|
||||||
|
child TEXT NULL,
|
||||||
|
allow INTEGER, -- 1 allow, 0 deny
|
||||||
|
reason TEXT
|
||||||
|
"""
|
||||||
|
|
||||||
|
source: str # identifier used for auditing (e.g., plugin name)
|
||||||
|
sql: str # SQL that SELECTs the 4 columns above
|
||||||
|
params: Dict[str, Any] # bound params for the SQL (values only; no ':' prefix)
|
||||||
|
|
||||||
|
|
||||||
|
def _namespace_params(i: int, params: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Rewrite parameter placeholders to distinct names per plugin block.
|
||||||
|
Returns (rewritten_sql, namespaced_params).
|
||||||
|
"""
|
||||||
|
|
||||||
|
replacements = {key: f"{key}_{i}" for key in params.keys()}
|
||||||
|
|
||||||
|
def rewrite(s: str) -> str:
|
||||||
|
for key in sorted(replacements.keys(), key=len, reverse=True):
|
||||||
|
s = s.replace(f":{key}", f":{replacements[key]}")
|
||||||
|
return s
|
||||||
|
|
||||||
|
namespaced: Dict[str, Any] = {}
|
||||||
|
for key, value in params.items():
|
||||||
|
namespaced[replacements[key]] = value
|
||||||
|
return rewrite, namespaced
|
||||||
|
|
||||||
|
|
||||||
|
PluginProvider = Callable[[str], PluginSQL]
|
||||||
|
PluginOrFactory = Union[PluginSQL, PluginProvider]
|
||||||
|
|
||||||
|
|
||||||
|
def build_rules_union(
|
||||||
|
actor: str, plugins: Sequence[PluginSQL]
|
||||||
|
) -> Tuple[str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Compose plugin SQL into a UNION ALL with namespaced parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
union_sql: a SELECT with columns (parent, child, allow, reason, source_plugin)
|
||||||
|
params: dict of bound parameters including :actor and namespaced plugin params
|
||||||
|
"""
|
||||||
|
parts: List[str] = []
|
||||||
|
params: Dict[str, Any] = {"actor": actor}
|
||||||
|
|
||||||
|
for i, p in enumerate(plugins):
|
||||||
|
rewrite, ns_params = _namespace_params(i, p.params)
|
||||||
|
sql_block = rewrite(p.sql)
|
||||||
|
params.update(ns_params)
|
||||||
|
|
||||||
|
parts.append(
|
||||||
|
f"""
|
||||||
|
SELECT parent, child, allow, reason, '{p.source}' AS source_plugin FROM (
|
||||||
|
{sql_block}
|
||||||
|
)
|
||||||
|
""".strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
# Empty UNION that returns no rows
|
||||||
|
union_sql = "SELECT NULL parent, NULL child, NULL allow, NULL reason, 'none' source_plugin WHERE 0"
|
||||||
|
else:
|
||||||
|
union_sql = "\nUNION ALL\n".join(parts)
|
||||||
|
|
||||||
|
return union_sql, params
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------
|
||||||
|
# Core resolvers (no temp tables, no custom UDFs)
|
||||||
|
# -----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_permissions_from_catalog(
|
||||||
|
db,
|
||||||
|
actor: str,
|
||||||
|
plugins: Sequence[PluginOrFactory],
|
||||||
|
action: str,
|
||||||
|
candidate_sql: str,
|
||||||
|
candidate_params: Optional[Dict[str, Any]] = None,
|
||||||
|
*,
|
||||||
|
implicit_deny: bool = True,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
|
||||||
|
|
||||||
|
Expectations:
|
||||||
|
- candidate_sql SELECTs: parent TEXT, child TEXT
|
||||||
|
(Use child=NULL for parent-scoped actions like "execute-sql".)
|
||||||
|
- *db* exposes: rows = await db.execute(sql, params)
|
||||||
|
where rows is an iterable of sqlite3.Row
|
||||||
|
- plugins are either PluginSQL objects or callables accepting (action: str)
|
||||||
|
and returning PluginSQL instances selecting (parent, child, allow, reason)
|
||||||
|
|
||||||
|
Decision policy:
|
||||||
|
1) Specificity first: child (depth=2) > parent (depth=1) > root (depth=0)
|
||||||
|
2) Within the same depth: deny (0) beats allow (1)
|
||||||
|
3) If no matching rule:
|
||||||
|
- implicit_deny=True -> treat as allow=0, reason='implicit deny'
|
||||||
|
- implicit_deny=False -> allow=None, reason=None
|
||||||
|
|
||||||
|
Returns: list of dict rows
|
||||||
|
- parent, child, allow, reason, source_plugin, depth
|
||||||
|
- resource (rendered "/parent/child" or "/parent" or "/")
|
||||||
|
"""
|
||||||
|
resolved_plugins: List[PluginSQL] = []
|
||||||
|
for plugin in plugins:
|
||||||
|
if callable(plugin) and not isinstance(plugin, PluginSQL):
|
||||||
|
resolved = plugin(action) # type: ignore[arg-type]
|
||||||
|
else:
|
||||||
|
resolved = plugin # type: ignore[assignment]
|
||||||
|
if not isinstance(resolved, PluginSQL):
|
||||||
|
raise TypeError("Plugin providers must return PluginSQL instances")
|
||||||
|
resolved_plugins.append(resolved)
|
||||||
|
|
||||||
|
union_sql, rule_params = build_rules_union(actor, resolved_plugins)
|
||||||
|
all_params = {
|
||||||
|
**(candidate_params or {}),
|
||||||
|
**rule_params,
|
||||||
|
"actor": actor,
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
WITH
|
||||||
|
cands AS (
|
||||||
|
{candidate_sql}
|
||||||
|
),
|
||||||
|
rules AS (
|
||||||
|
{union_sql}
|
||||||
|
),
|
||||||
|
matched AS (
|
||||||
|
SELECT
|
||||||
|
c.parent, c.child,
|
||||||
|
r.allow, r.reason, r.source_plugin,
|
||||||
|
CASE
|
||||||
|
WHEN r.child IS NOT NULL THEN 2 -- child-level (most specific)
|
||||||
|
WHEN r.parent IS NOT NULL THEN 1 -- parent-level
|
||||||
|
ELSE 0 -- root/global
|
||||||
|
END AS depth
|
||||||
|
FROM cands c
|
||||||
|
JOIN rules r
|
||||||
|
ON (r.parent IS NULL OR r.parent = c.parent)
|
||||||
|
AND (r.child IS NULL OR r.child = c.child)
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY parent, child
|
||||||
|
ORDER BY
|
||||||
|
depth DESC, -- specificity first
|
||||||
|
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- deny over allow at same depth
|
||||||
|
source_plugin -- stable tie-break
|
||||||
|
) AS rn
|
||||||
|
FROM matched
|
||||||
|
),
|
||||||
|
winner AS (
|
||||||
|
SELECT parent, child,
|
||||||
|
allow, reason, source_plugin, depth
|
||||||
|
FROM ranked WHERE rn = 1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.parent, c.child,
|
||||||
|
COALESCE(w.allow, CASE WHEN :implicit_deny THEN 0 ELSE NULL END) AS allow,
|
||||||
|
COALESCE(w.reason, CASE WHEN :implicit_deny THEN 'implicit deny' ELSE NULL END) AS reason,
|
||||||
|
w.source_plugin,
|
||||||
|
COALESCE(w.depth, -1) AS depth,
|
||||||
|
:action AS action,
|
||||||
|
CASE
|
||||||
|
WHEN c.parent IS NULL THEN '/'
|
||||||
|
WHEN c.child IS NULL THEN '/' || c.parent
|
||||||
|
ELSE '/' || c.parent || '/' || c.child
|
||||||
|
END AS resource
|
||||||
|
FROM cands c
|
||||||
|
LEFT JOIN winner w
|
||||||
|
ON ((w.parent = c.parent) OR (w.parent IS NULL AND c.parent IS NULL))
|
||||||
|
AND ((w.child = c.child ) OR (w.child IS NULL AND c.child IS NULL))
|
||||||
|
ORDER BY c.parent, c.child
|
||||||
|
"""
|
||||||
|
|
||||||
|
rows_iter: Iterable[sqlite3.Row] = await db.execute(
|
||||||
|
sql,
|
||||||
|
{**all_params, "implicit_deny": 1 if implicit_deny else 0},
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows_iter]
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_permissions_with_candidates(
|
||||||
|
db,
|
||||||
|
actor: str,
|
||||||
|
plugins: Sequence[PluginOrFactory],
|
||||||
|
candidates: List[Tuple[str, Optional[str]]],
|
||||||
|
action: str,
|
||||||
|
*,
|
||||||
|
implicit_deny: bool = True,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Resolve permissions without any external candidate table by embedding
|
||||||
|
the candidates as a UNION of parameterized SELECTs in a CTE.
|
||||||
|
|
||||||
|
candidates: list of (parent, child) where child can be None for parent-scoped actions.
|
||||||
|
"""
|
||||||
|
# Build a small CTE for candidates.
|
||||||
|
cand_rows_sql: List[str] = []
|
||||||
|
cand_params: Dict[str, Any] = {}
|
||||||
|
for i, (parent, child) in enumerate(candidates):
|
||||||
|
pkey = f"cand_p_{i}"
|
||||||
|
ckey = f"cand_c_{i}"
|
||||||
|
cand_params[pkey] = parent
|
||||||
|
cand_params[ckey] = child
|
||||||
|
cand_rows_sql.append(f"SELECT :{pkey} AS parent, :{ckey} AS child")
|
||||||
|
candidate_sql = (
|
||||||
|
"\nUNION ALL\n".join(cand_rows_sql)
|
||||||
|
if cand_rows_sql
|
||||||
|
else "SELECT NULL AS parent, NULL AS child WHERE 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await resolve_permissions_from_catalog(
|
||||||
|
db,
|
||||||
|
actor,
|
||||||
|
plugins,
|
||||||
|
action,
|
||||||
|
candidate_sql=candidate_sql,
|
||||||
|
candidate_params=cand_params,
|
||||||
|
implicit_deny=implicit_deny,
|
||||||
|
)
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
import time
|
import time
|
||||||
|
|
@ -173,6 +174,24 @@ class BaseView:
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def respond_json_or_html(self, request, data, filename):
|
||||||
|
"""Return JSON or HTML with pretty JSON depending on format parameter."""
|
||||||
|
as_format = request.url_vars.get("format")
|
||||||
|
if as_format:
|
||||||
|
headers = {}
|
||||||
|
if self.ds.cors:
|
||||||
|
add_cors_headers(headers)
|
||||||
|
return Response.json(data, headers=headers)
|
||||||
|
else:
|
||||||
|
return await self.render(
|
||||||
|
["show_json.html"],
|
||||||
|
request=request,
|
||||||
|
context={
|
||||||
|
"filename": filename,
|
||||||
|
"data_json": json.dumps(data, indent=4, default=repr),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def as_view(cls, *class_args, **class_kwargs):
|
def as_view(cls, *class_args, **class_kwargs):
|
||||||
async def view(request, send):
|
async def view(request, send):
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,32 @@
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
||||||
from datasette.utils.asgi import Response, Forbidden
|
from datasette.utils.asgi import Response, Forbidden
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
actor_matches_allow,
|
actor_matches_allow,
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
|
await_me_maybe,
|
||||||
tilde_encode,
|
tilde_encode,
|
||||||
tilde_decode,
|
tilde_decode,
|
||||||
)
|
)
|
||||||
|
from datasette.utils.permissions import PluginSQL, resolve_permissions_from_catalog
|
||||||
|
from datasette.plugins import pm
|
||||||
from .base import BaseView, View
|
from .base import BaseView, View
|
||||||
import secrets
|
import secrets
|
||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_path(parent, child):
|
||||||
|
if parent is None:
|
||||||
|
return "/"
|
||||||
|
if child is None:
|
||||||
|
return f"/{parent}"
|
||||||
|
return f"/{parent}/{child}"
|
||||||
|
|
||||||
|
|
||||||
class JsonDataView(BaseView):
|
class JsonDataView(BaseView):
|
||||||
name = "json_data"
|
name = "json_data"
|
||||||
|
|
||||||
|
|
@ -30,32 +45,13 @@ class JsonDataView(BaseView):
|
||||||
self.permission = permission
|
self.permission = permission
|
||||||
|
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
as_format = request.url_vars["format"]
|
|
||||||
if self.permission:
|
if self.permission:
|
||||||
await self.ds.ensure_permissions(request.actor, [self.permission])
|
await self.ds.ensure_permissions(request.actor, [self.permission])
|
||||||
if self.needs_request:
|
if self.needs_request:
|
||||||
data = self.data_callback(request)
|
data = self.data_callback(request)
|
||||||
else:
|
else:
|
||||||
data = self.data_callback()
|
data = self.data_callback()
|
||||||
if as_format:
|
return await self.respond_json_or_html(request, data, self.filename)
|
||||||
headers = {}
|
|
||||||
if self.ds.cors:
|
|
||||||
add_cors_headers(headers)
|
|
||||||
return Response(
|
|
||||||
json.dumps(data, default=repr),
|
|
||||||
content_type="application/json; charset=utf-8",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
return await self.render(
|
|
||||||
["show_json.html"],
|
|
||||||
request=request,
|
|
||||||
context={
|
|
||||||
"filename": self.filename,
|
|
||||||
"data_json": json.dumps(data, indent=4, default=repr),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PatternPortfolioView(View):
|
class PatternPortfolioView(View):
|
||||||
|
|
@ -187,6 +183,402 @@ class PermissionsDebugView(BaseView):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AllowedResourcesView(BaseView):
|
||||||
|
name = "allowed"
|
||||||
|
has_json_alternate = False
|
||||||
|
|
||||||
|
CANDIDATE_SQL = {
|
||||||
|
"view-table": (
|
||||||
|
"SELECT database_name AS parent, table_name AS child FROM catalog_tables",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"view-database": (
|
||||||
|
"SELECT database_name AS parent, NULL AS child FROM catalog_databases",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"view-instance": ("SELECT NULL AS parent, NULL AS child", {}),
|
||||||
|
"execute-sql": (
|
||||||
|
"SELECT database_name AS parent, NULL AS child FROM catalog_databases",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
await self.ds.refresh_schemas()
|
||||||
|
|
||||||
|
# Check if user has permissions-debug (to show sensitive fields)
|
||||||
|
has_debug_permission = await self.ds.permission_allowed(
|
||||||
|
request.actor, "permissions-debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if this is a request for JSON (has .json extension)
|
||||||
|
as_format = request.url_vars.get("format")
|
||||||
|
|
||||||
|
if not as_format:
|
||||||
|
# Render the HTML form (even if query parameters are present)
|
||||||
|
return await self.render(
|
||||||
|
["debug_allowed.html"],
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
"supported_actions": sorted(self.CANDIDATE_SQL.keys()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON API - action parameter is required
|
||||||
|
action = request.args.get("action")
|
||||||
|
if not action:
|
||||||
|
return Response.json({"error": "action parameter is required"}, status=400)
|
||||||
|
if action not in self.ds.permissions:
|
||||||
|
return Response.json({"error": f"Unknown action: {action}"}, status=404)
|
||||||
|
if action not in self.CANDIDATE_SQL:
|
||||||
|
return Response.json(
|
||||||
|
{"error": f"Action '{action}' is not supported by this endpoint"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
actor = request.actor if isinstance(request.actor, dict) else None
|
||||||
|
parent_filter = request.args.get("parent")
|
||||||
|
child_filter = request.args.get("child")
|
||||||
|
if child_filter and not parent_filter:
|
||||||
|
return Response.json(
|
||||||
|
{"error": "parent must be provided when child is specified"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = int(request.args.get("page", "1"))
|
||||||
|
page_size = int(request.args.get("page_size", "50"))
|
||||||
|
except ValueError:
|
||||||
|
return Response.json(
|
||||||
|
{"error": "page and page_size must be integers"}, status=400
|
||||||
|
)
|
||||||
|
if page < 1:
|
||||||
|
return Response.json({"error": "page must be >= 1"}, status=400)
|
||||||
|
if page_size < 1:
|
||||||
|
return Response.json({"error": "page_size must be >= 1"}, status=400)
|
||||||
|
max_page_size = 200
|
||||||
|
if page_size > max_page_size:
|
||||||
|
page_size = max_page_size
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
candidate_sql, candidate_params = self.CANDIDATE_SQL[action]
|
||||||
|
|
||||||
|
db = self.ds.get_internal_database()
|
||||||
|
required_tables = set()
|
||||||
|
if "catalog_tables" in candidate_sql:
|
||||||
|
required_tables.add("catalog_tables")
|
||||||
|
if "catalog_databases" in candidate_sql:
|
||||||
|
required_tables.add("catalog_databases")
|
||||||
|
|
||||||
|
for table in required_tables:
|
||||||
|
if not await db.table_exists(table):
|
||||||
|
headers = {}
|
||||||
|
if self.ds.cors:
|
||||||
|
add_cors_headers(headers)
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
"action": action,
|
||||||
|
"actor_id": (actor or {}).get("id") if actor else None,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": 0,
|
||||||
|
"items": [],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
plugins = []
|
||||||
|
for block in pm.hook.permission_resources_sql(
|
||||||
|
datasette=self.ds,
|
||||||
|
actor=actor,
|
||||||
|
action=action,
|
||||||
|
):
|
||||||
|
block = await await_me_maybe(block)
|
||||||
|
if block is None:
|
||||||
|
continue
|
||||||
|
if isinstance(block, (list, tuple)):
|
||||||
|
candidates = block
|
||||||
|
else:
|
||||||
|
candidates = [block]
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(candidate, PluginSQL):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping permission_resources_sql result %r from plugin; expected PluginSQL",
|
||||||
|
candidate,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
plugins.append(candidate)
|
||||||
|
|
||||||
|
actor_id = actor.get("id") if actor else None
|
||||||
|
rows = await resolve_permissions_from_catalog(
|
||||||
|
db,
|
||||||
|
actor=str(actor_id) if actor_id is not None else "",
|
||||||
|
plugins=plugins,
|
||||||
|
action=action,
|
||||||
|
candidate_sql=candidate_sql,
|
||||||
|
candidate_params=candidate_params,
|
||||||
|
implicit_deny=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_rows = [row for row in rows if row["allow"] == 1]
|
||||||
|
if parent_filter is not None:
|
||||||
|
allowed_rows = [
|
||||||
|
row for row in allowed_rows if row["parent"] == parent_filter
|
||||||
|
]
|
||||||
|
if child_filter is not None:
|
||||||
|
allowed_rows = [row for row in allowed_rows if row["child"] == child_filter]
|
||||||
|
total = len(allowed_rows)
|
||||||
|
paged_rows = allowed_rows[offset : offset + page_size]
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in paged_rows:
|
||||||
|
item = {
|
||||||
|
"parent": row["parent"],
|
||||||
|
"child": row["child"],
|
||||||
|
"resource": row["resource"],
|
||||||
|
}
|
||||||
|
# Only include sensitive fields if user has permissions-debug
|
||||||
|
if has_debug_permission:
|
||||||
|
item["reason"] = row["reason"]
|
||||||
|
item["source_plugin"] = row["source_plugin"]
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
def build_page_url(page_number):
|
||||||
|
pairs = []
|
||||||
|
for key in request.args:
|
||||||
|
if key in {"page", "page_size"}:
|
||||||
|
continue
|
||||||
|
for value in request.args.getlist(key):
|
||||||
|
pairs.append((key, value))
|
||||||
|
pairs.append(("page", str(page_number)))
|
||||||
|
pairs.append(("page_size", str(page_size)))
|
||||||
|
query = urllib.parse.urlencode(pairs)
|
||||||
|
return f"{request.path}?{query}"
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"action": action,
|
||||||
|
"actor_id": actor_id,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": total,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
if total > offset + page_size:
|
||||||
|
response["next_url"] = build_page_url(page + 1)
|
||||||
|
if page > 1:
|
||||||
|
response["previous_url"] = build_page_url(page - 1)
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if self.ds.cors:
|
||||||
|
add_cors_headers(headers)
|
||||||
|
return Response.json(response, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionRulesView(BaseView):
|
||||||
|
name = "permission_rules"
|
||||||
|
has_json_alternate = False
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
await self.ds.ensure_permissions(request.actor, ["view-instance"])
|
||||||
|
if not await self.ds.permission_allowed(request.actor, "permissions-debug"):
|
||||||
|
raise Forbidden("Permission denied")
|
||||||
|
|
||||||
|
# Check if this is a request for JSON (has .json extension)
|
||||||
|
as_format = request.url_vars.get("format")
|
||||||
|
|
||||||
|
if not as_format:
|
||||||
|
# Render the HTML form (even if query parameters are present)
|
||||||
|
return await self.render(
|
||||||
|
["debug_rules.html"],
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
"sorted_permissions": sorted(self.ds.permissions.keys()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON API - action parameter is required
|
||||||
|
action = request.args.get("action")
|
||||||
|
if not action:
|
||||||
|
return Response.json({"error": "action parameter is required"}, status=400)
|
||||||
|
if action not in self.ds.permissions:
|
||||||
|
return Response.json({"error": f"Unknown action: {action}"}, status=404)
|
||||||
|
|
||||||
|
actor = request.actor if isinstance(request.actor, dict) else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = int(request.args.get("page", "1"))
|
||||||
|
page_size = int(request.args.get("page_size", "50"))
|
||||||
|
except ValueError:
|
||||||
|
return Response.json(
|
||||||
|
{"error": "page and page_size must be integers"}, status=400
|
||||||
|
)
|
||||||
|
if page < 1:
|
||||||
|
return Response.json({"error": "page must be >= 1"}, status=400)
|
||||||
|
if page_size < 1:
|
||||||
|
return Response.json({"error": "page_size must be >= 1"}, status=400)
|
||||||
|
max_page_size = 200
|
||||||
|
if page_size > max_page_size:
|
||||||
|
page_size = max_page_size
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
union_sql, union_params = await self.ds.allowed_resources_sql(actor, action)
|
||||||
|
await self.ds.refresh_schemas()
|
||||||
|
db = self.ds.get_internal_database()
|
||||||
|
|
||||||
|
count_query = f"""
|
||||||
|
WITH rules AS (
|
||||||
|
{union_sql}
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM rules
|
||||||
|
"""
|
||||||
|
count_row = (await db.execute(count_query, union_params)).first()
|
||||||
|
total = count_row["count"] if count_row else 0
|
||||||
|
|
||||||
|
data_query = f"""
|
||||||
|
WITH rules AS (
|
||||||
|
{union_sql}
|
||||||
|
)
|
||||||
|
SELECT parent, child, allow, reason, source_plugin
|
||||||
|
FROM rules
|
||||||
|
ORDER BY allow DESC, (parent IS NOT NULL), parent, child
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
params = {**union_params, "limit": page_size, "offset": offset}
|
||||||
|
rows = await db.execute(data_query, params)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
parent = row["parent"]
|
||||||
|
child = row["child"]
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"parent": parent,
|
||||||
|
"child": child,
|
||||||
|
"resource": _resource_path(parent, child),
|
||||||
|
"allow": row["allow"],
|
||||||
|
"reason": row["reason"],
|
||||||
|
"source_plugin": row["source_plugin"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_page_url(page_number):
|
||||||
|
pairs = []
|
||||||
|
for key in request.args:
|
||||||
|
if key in {"page", "page_size"}:
|
||||||
|
continue
|
||||||
|
for value in request.args.getlist(key):
|
||||||
|
pairs.append((key, value))
|
||||||
|
pairs.append(("page", str(page_number)))
|
||||||
|
pairs.append(("page_size", str(page_size)))
|
||||||
|
query = urllib.parse.urlencode(pairs)
|
||||||
|
return f"{request.path}?{query}"
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"action": action,
|
||||||
|
"actor_id": (actor or {}).get("id") if actor else None,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": total,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
if total > offset + page_size:
|
||||||
|
response["next_url"] = build_page_url(page + 1)
|
||||||
|
if page > 1:
|
||||||
|
response["previous_url"] = build_page_url(page - 1)
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if self.ds.cors:
|
||||||
|
add_cors_headers(headers)
|
||||||
|
return Response.json(response, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionCheckView(BaseView):
|
||||||
|
name = "permission_check"
|
||||||
|
has_json_alternate = False
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
# Check if user has permissions-debug (to show sensitive fields)
|
||||||
|
has_debug_permission = await self.ds.permission_allowed(
|
||||||
|
request.actor, "permissions-debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if this is a request for JSON (has .json extension)
|
||||||
|
as_format = request.url_vars.get("format")
|
||||||
|
|
||||||
|
if not as_format:
|
||||||
|
# Render the HTML form (even if query parameters are present)
|
||||||
|
return await self.render(
|
||||||
|
["debug_check.html"],
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
"sorted_permissions": sorted(self.ds.permissions.keys()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON API - action parameter is required
|
||||||
|
action = request.args.get("action")
|
||||||
|
if not action:
|
||||||
|
return Response.json({"error": "action parameter is required"}, status=400)
|
||||||
|
if action not in self.ds.permissions:
|
||||||
|
return Response.json({"error": f"Unknown action: {action}"}, status=404)
|
||||||
|
|
||||||
|
parent = request.args.get("parent")
|
||||||
|
child = request.args.get("child")
|
||||||
|
if child and not parent:
|
||||||
|
return Response.json(
|
||||||
|
{"error": "parent is required when child is provided"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
if parent and child:
|
||||||
|
resource = (parent, child)
|
||||||
|
elif parent:
|
||||||
|
resource = parent
|
||||||
|
else:
|
||||||
|
resource = None
|
||||||
|
|
||||||
|
before_checks = len(self.ds._permission_checks)
|
||||||
|
allowed = await self.ds.permission_allowed_2(request.actor, action, resource)
|
||||||
|
|
||||||
|
info = None
|
||||||
|
if len(self.ds._permission_checks) > before_checks:
|
||||||
|
for check in reversed(self.ds._permission_checks):
|
||||||
|
if (
|
||||||
|
check.get("actor") == request.actor
|
||||||
|
and check.get("action") == action
|
||||||
|
and check.get("resource") == resource
|
||||||
|
):
|
||||||
|
info = check
|
||||||
|
break
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"action": action,
|
||||||
|
"allowed": bool(allowed),
|
||||||
|
"resource": {
|
||||||
|
"parent": parent,
|
||||||
|
"child": child,
|
||||||
|
"path": _resource_path(parent, child),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.actor and "id" in request.actor:
|
||||||
|
response["actor_id"] = request.actor["id"]
|
||||||
|
|
||||||
|
if info is not None:
|
||||||
|
response["used_default"] = info.get("used_default")
|
||||||
|
response["depth"] = info.get("depth")
|
||||||
|
# Only include sensitive fields if user has permissions-debug
|
||||||
|
if has_debug_permission:
|
||||||
|
response["reason"] = info.get("reason")
|
||||||
|
response["source_plugin"] = info.get("source_plugin")
|
||||||
|
|
||||||
|
return Response.json(response)
|
||||||
|
|
||||||
|
|
||||||
class AllowDebugView(BaseView):
|
class AllowDebugView(BaseView):
|
||||||
name = "allow_debug"
|
name = "allow_debug"
|
||||||
has_json_alternate = False
|
has_json_alternate = False
|
||||||
|
|
|
||||||
|
|
@ -1050,6 +1050,62 @@ It also provides an interface for running hypothetical permission checks against
|
||||||
|
|
||||||
This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system.
|
This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system.
|
||||||
|
|
||||||
|
.. _AllowedResourcesView:
|
||||||
|
|
||||||
|
Allowed resources view
|
||||||
|
======================
|
||||||
|
|
||||||
|
The ``/-/allowed`` endpoint displays resources that the current actor can access for a supplied ``action`` query string argument.
|
||||||
|
|
||||||
|
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/allowed.json``) to get the raw JSON response instead.
|
||||||
|
|
||||||
|
Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair.
|
||||||
|
|
||||||
|
This endpoint is publicly accessible to help users understand their own permissions. However, potentially sensitive fields (``reason`` and ``source_plugin``) are only included in responses for users with the ``permissions-debug`` permission.
|
||||||
|
|
||||||
|
Datasette includes helper endpoints for exploring the action-based permission resolver:
|
||||||
|
|
||||||
|
``/-/allowed``
|
||||||
|
Returns a paginated list of resources that the current actor is allowed to access for a given action. Pass ``?action=view-table`` (or another action) to select the action, and optional ``parent=``/``child=`` query parameters to narrow the results to a specific database/table pair.
|
||||||
|
|
||||||
|
``/-/rules``
|
||||||
|
Lists the raw permission rules (both allow and deny) contributing to each resource for the supplied action. This includes configuration-derived and plugin-provided rules. **Requires the permissions-debug permission** (only available to the root user by default).
|
||||||
|
|
||||||
|
``/-/check``
|
||||||
|
Evaluates whether the current actor can perform ``action`` against an optional ``parent``/``child`` resource tuple, returning the winning rule and reason.
|
||||||
|
|
||||||
|
These endpoints work in conjunction with :ref:`plugin_hook_permission_resources_sql` and make it easier to verify that configuration allow blocks and plugins are behaving as intended.
|
||||||
|
|
||||||
|
All three endpoints support both HTML and JSON responses. Visit the endpoint directly for an interactive HTML form interface, or add ``.json`` to the URL for a raw JSON response.
|
||||||
|
|
||||||
|
**Security note:** The ``/-/check`` and ``/-/allowed`` endpoints are publicly accessible to help users understand their own permissions. However, potentially sensitive fields (``reason`` and ``source_plugin``) are only included in responses for users with the ``permissions-debug`` permission. The ``/-/rules`` endpoint requires the ``permissions-debug`` permission for all access.
|
||||||
|
|
||||||
|
.. _PermissionRulesView:
|
||||||
|
|
||||||
|
Permission rules view
|
||||||
|
======================
|
||||||
|
|
||||||
|
The ``/-/rules`` endpoint displays all permission rules (both allow and deny) for each candidate resource for the requested action.
|
||||||
|
|
||||||
|
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/rules.json?action=view-table``) to get the raw JSON response instead.
|
||||||
|
|
||||||
|
Pass ``?action=`` as a query parameter to specify which action to check.
|
||||||
|
|
||||||
|
**Requires the permissions-debug permission** - this endpoint returns a 403 Forbidden error for users without this permission. The :ref:`root user <authentication_root>` has this permission by default.
|
||||||
|
|
||||||
|
.. _PermissionCheckView:
|
||||||
|
|
||||||
|
Permission check view
|
||||||
|
======================
|
||||||
|
|
||||||
|
The ``/-/check`` endpoint evaluates a single action/resource pair and returns information indicating whether the access was allowed along with diagnostic information.
|
||||||
|
|
||||||
|
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/check.json?action=view-instance``) to get the raw JSON response instead.
|
||||||
|
|
||||||
|
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource.
|
||||||
|
|
||||||
|
This endpoint is publicly accessible to help users understand their own permissions. However, potentially sensitive fields (``reason`` and ``source_plugin``) are only included in responses for users with the ``permissions-debug`` permission.
|
||||||
|
|
||||||
.. _authentication_ds_actor:
|
.. _authentication_ds_actor:
|
||||||
|
|
||||||
The ds_actor cookie
|
The ds_actor cookie
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ General guidelines
|
||||||
* **main should always be releasable**. Incomplete features should live in branches. This ensures that any small bug fixes can be quickly released.
|
* **main should always be releasable**. Incomplete features should live in branches. This ensures that any small bug fixes can be quickly released.
|
||||||
* **The ideal commit** should bundle together the implementation, unit tests and associated documentation updates. The commit message should link to an associated issue.
|
* **The ideal commit** should bundle together the implementation, unit tests and associated documentation updates. The commit message should link to an associated issue.
|
||||||
* **New plugin hooks** should only be shipped if accompanied by a separate release of a non-demo plugin that uses them.
|
* **New plugin hooks** should only be shipped if accompanied by a separate release of a non-demo plugin that uses them.
|
||||||
|
* **New user-facing views and documentation** should be added or updated alongside their implementation. The `/docs` folder includes pages for plugin hooks and built-in views—please ensure any new hooks or views are reflected there so the documentation tests continue to pass.
|
||||||
|
|
||||||
.. _devenvironment:
|
.. _devenvironment:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1290,12 +1290,13 @@ Here's an example that allows users to view the ``admin_log`` table only if thei
|
||||||
if not actor:
|
if not actor:
|
||||||
return False
|
return False
|
||||||
user_id = actor["id"]
|
user_id = actor["id"]
|
||||||
return await datasette.get_database(
|
result = await datasette.get_database(
|
||||||
"staff"
|
"staff"
|
||||||
).execute(
|
).execute(
|
||||||
"select count(*) from admin_users where user_id = :user_id",
|
"select count(*) from admin_users where user_id = :user_id",
|
||||||
{"user_id": user_id},
|
{"user_id": user_id},
|
||||||
)
|
)
|
||||||
|
return result.first()[0] > 0
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
@ -1303,6 +1304,184 @@ See :ref:`built-in permissions <permissions>` for a full list of permissions tha
|
||||||
|
|
||||||
Example: `datasette-permissions-sql <https://datasette.io/plugins/datasette-permissions-sql>`_
|
Example: `datasette-permissions-sql <https://datasette.io/plugins/datasette-permissions-sql>`_
|
||||||
|
|
||||||
|
.. _plugin_hook_permission_resources_sql:
|
||||||
|
|
||||||
|
permission_resources_sql(datasette, actor, action)
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
``datasette`` - :ref:`internals_datasette`
|
||||||
|
Access to the Datasette instance.
|
||||||
|
|
||||||
|
``actor`` - dictionary or None
|
||||||
|
The current actor dictionary. ``None`` for anonymous requests.
|
||||||
|
|
||||||
|
``action`` - string
|
||||||
|
The permission action being evaluated. Examples include ``"view-table"`` or ``"insert-row"``.
|
||||||
|
|
||||||
|
Return value
|
||||||
|
A :class:`datasette.utils.permissions.PluginSQL` object, ``None`` or an iterable of ``PluginSQL`` objects.
|
||||||
|
|
||||||
|
Datasette's action-based permission resolver calls this hook to gather SQL rows describing which
|
||||||
|
resources an actor may access (``allow = 1``) or should be denied (``allow = 0``) for a specific action.
|
||||||
|
Each SQL snippet should return ``parent``, ``child``, ``allow`` and ``reason`` columns. Any bound parameters
|
||||||
|
supplied via ``PluginSQL.params`` are automatically namespaced per plugin.
|
||||||
|
|
||||||
|
|
||||||
|
Permission plugin examples
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
These snippets show how to use the new ``permission_resources_sql`` hook to
|
||||||
|
contribute rows to the action-based permission resolver. Each hook receives the
|
||||||
|
current actor dictionary (or ``None``) and must return ``None`` or an instance or list of
|
||||||
|
``datasette.utils.permissions.PluginSQL`` (or a coroutine that resolves to that).
|
||||||
|
|
||||||
|
Allow Alice to view a specific table
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
This plugin grants the actor with ``id == "alice"`` permission to perform the
|
||||||
|
``view-table`` action against the ``sales`` table inside the ``accounting`` database.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
from datasette.utils.permissions import PluginSQL
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def permission_resources_sql(datasette, actor, action):
|
||||||
|
if action != "view-table":
|
||||||
|
return None
|
||||||
|
if not actor or actor.get("id") != "alice":
|
||||||
|
return None
|
||||||
|
|
||||||
|
return PluginSQL(
|
||||||
|
source="alice_sales_allow",
|
||||||
|
sql="""
|
||||||
|
SELECT
|
||||||
|
'accounting' AS parent,
|
||||||
|
'sales' AS child,
|
||||||
|
1 AS allow,
|
||||||
|
'alice can view accounting/sales' AS reason
|
||||||
|
""",
|
||||||
|
params={},
|
||||||
|
)
|
||||||
|
|
||||||
|
Restrict execute-sql to a database prefix
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Only allow ``execute-sql`` against databases whose name begins with
|
||||||
|
``analytics_``. This shows how to use parameters that the permission resolver
|
||||||
|
will pass through to the SQL snippet.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
from datasette.utils.permissions import PluginSQL
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def permission_resources_sql(datasette, actor, action):
|
||||||
|
if action != "execute-sql":
|
||||||
|
return None
|
||||||
|
|
||||||
|
return PluginSQL(
|
||||||
|
source="analytics_execute_sql",
|
||||||
|
sql="""
|
||||||
|
SELECT
|
||||||
|
parent,
|
||||||
|
NULL AS child,
|
||||||
|
1 AS allow,
|
||||||
|
'execute-sql allowed for analytics_*' AS reason
|
||||||
|
FROM catalog_databases
|
||||||
|
WHERE database_name LIKE :prefix
|
||||||
|
""",
|
||||||
|
params={
|
||||||
|
"prefix": "analytics_%",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Read permissions from a custom table
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
This example stores grants in an internal table called ``permission_grants``
|
||||||
|
with columns ``(actor_id, action, parent, child, allow, reason)``.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
from datasette.utils.permissions import PluginSQL
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def permission_resources_sql(datasette, actor, action):
|
||||||
|
if not actor:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return PluginSQL(
|
||||||
|
source="permission_grants_table",
|
||||||
|
sql="""
|
||||||
|
SELECT
|
||||||
|
parent,
|
||||||
|
child,
|
||||||
|
allow,
|
||||||
|
COALESCE(reason, 'permission_grants table') AS reason
|
||||||
|
FROM permission_grants
|
||||||
|
WHERE actor_id = :actor_id
|
||||||
|
AND action = :action
|
||||||
|
""",
|
||||||
|
params={
|
||||||
|
"actor_id": actor.get("id"),
|
||||||
|
"action": action,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Default deny with an exception
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Combine a root-level deny with a specific table allow for trusted users.
|
||||||
|
The resolver will automatically apply the most specific rule.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
from datasette.utils.permissions import PluginSQL
|
||||||
|
|
||||||
|
|
||||||
|
TRUSTED = {"alice", "bob"}
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def permission_resources_sql(datasette, actor, action):
|
||||||
|
if action != "view-table":
|
||||||
|
return None
|
||||||
|
|
||||||
|
actor_id = (actor or {}).get("id")
|
||||||
|
|
||||||
|
if actor_id not in TRUSTED:
|
||||||
|
return PluginSQL(
|
||||||
|
source="view_table_root_deny",
|
||||||
|
sql="""
|
||||||
|
SELECT NULL AS parent, NULL AS child, 0 AS allow,
|
||||||
|
'default deny view-table' AS reason
|
||||||
|
""",
|
||||||
|
params={},
|
||||||
|
)
|
||||||
|
|
||||||
|
return PluginSQL(
|
||||||
|
source="trusted_allow",
|
||||||
|
sql="""
|
||||||
|
SELECT NULL AS parent, NULL AS child, 0 AS allow,
|
||||||
|
'default deny view-table' AS reason
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'reports' AS parent, 'daily_metrics' AS child, 1 AS allow,
|
||||||
|
'trusted user access' AS reason
|
||||||
|
""",
|
||||||
|
params={"actor_id": actor_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
The ``UNION ALL`` ensures the deny rule is always present, while the second row
|
||||||
|
adds the exception for trusted users.
|
||||||
|
|
||||||
.. _plugin_hook_register_magic_parameters:
|
.. _plugin_hook_register_magic_parameters:
|
||||||
|
|
||||||
register_magic_parameters(datasette)
|
register_magic_parameters(datasette)
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,7 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
|
||||||
"hooks": [
|
"hooks": [
|
||||||
"actor_from_request",
|
"actor_from_request",
|
||||||
"permission_allowed",
|
"permission_allowed",
|
||||||
|
"permission_resources_sql",
|
||||||
"register_permissions",
|
"register_permissions",
|
||||||
"skip_csrf"
|
"skip_csrf"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,4 @@ filterwarnings=
|
||||||
ignore:Using or importing the ABCs::bs4.element
|
ignore:Using or importing the ABCs::bs4.element
|
||||||
markers =
|
markers =
|
||||||
serial: tests to avoid using with pytest-xdist
|
serial: tests to avoid using with pytest-xdist
|
||||||
asyncio_mode = strict
|
asyncio_mode = strict
|
||||||
asyncio_default_fixture_loop_scope = function
|
|
||||||
2
setup.py
2
setup.py
|
|
@ -80,7 +80,7 @@ setup(
|
||||||
"test": [
|
"test": [
|
||||||
"pytest>=5.2.2",
|
"pytest>=5.2.2",
|
||||||
"pytest-xdist>=2.2.1",
|
"pytest-xdist>=2.2.1",
|
||||||
"pytest-asyncio>=0.17",
|
"pytest-asyncio>=1.2.0",
|
||||||
"beautifulsoup4>=4.8.1",
|
"beautifulsoup4>=4.8.1",
|
||||||
"black==25.1.0",
|
"black==25.1.0",
|
||||||
"blacken-docs==1.19.1",
|
"blacken-docs==1.19.1",
|
||||||
|
|
|
||||||
118
tests/test_config_permission_rules.py
Normal file
118
tests/test_config_permission_rules.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from datasette.app import Datasette
|
||||||
|
from datasette.database import Database
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_datasette(config=None, databases=None):
|
||||||
|
ds = Datasette(memory=True, config=config)
|
||||||
|
for name in databases or []:
|
||||||
|
ds.add_database(Database(ds, memory_name=f"{name}_memory"), name=name)
|
||||||
|
await ds.invoke_startup()
|
||||||
|
await ds.refresh_schemas()
|
||||||
|
return ds
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_root_permissions_allow():
|
||||||
|
config = {"permissions": {"execute-sql": {"id": "alice"}}}
|
||||||
|
ds = await setup_datasette(config=config, databases=["content"])
|
||||||
|
|
||||||
|
assert await ds.permission_allowed_2({"id": "alice"}, "execute-sql", "content")
|
||||||
|
assert not await ds.permission_allowed_2({"id": "bob"}, "execute-sql", "content")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_database_permission():
|
||||||
|
config = {
|
||||||
|
"databases": {
|
||||||
|
"content": {
|
||||||
|
"permissions": {
|
||||||
|
"insert-row": {"id": "alice"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ds = await setup_datasette(config=config, databases=["content"])
|
||||||
|
|
||||||
|
assert await ds.permission_allowed_2(
|
||||||
|
{"id": "alice"}, "insert-row", ("content", "repos")
|
||||||
|
)
|
||||||
|
assert not await ds.permission_allowed_2(
|
||||||
|
{"id": "bob"}, "insert-row", ("content", "repos")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_table_permission():
|
||||||
|
config = {
|
||||||
|
"databases": {
|
||||||
|
"content": {
|
||||||
|
"tables": {"repos": {"permissions": {"delete-row": {"id": "alice"}}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ds = await setup_datasette(config=config, databases=["content"])
|
||||||
|
|
||||||
|
assert await ds.permission_allowed_2(
|
||||||
|
{"id": "alice"}, "delete-row", ("content", "repos")
|
||||||
|
)
|
||||||
|
assert not await ds.permission_allowed_2(
|
||||||
|
{"id": "bob"}, "delete-row", ("content", "repos")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_view_table_allow_block():
|
||||||
|
config = {
|
||||||
|
"databases": {"content": {"tables": {"repos": {"allow": {"id": "alice"}}}}}
|
||||||
|
}
|
||||||
|
ds = await setup_datasette(config=config, databases=["content"])
|
||||||
|
|
||||||
|
assert await ds.permission_allowed_2(
|
||||||
|
{"id": "alice"}, "view-table", ("content", "repos")
|
||||||
|
)
|
||||||
|
assert not await ds.permission_allowed_2(
|
||||||
|
{"id": "bob"}, "view-table", ("content", "repos")
|
||||||
|
)
|
||||||
|
assert await ds.permission_allowed_2(
|
||||||
|
{"id": "bob"}, "view-table", ("content", "other")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_view_table_allow_false_blocks():
|
||||||
|
config = {"databases": {"content": {"tables": {"repos": {"allow": False}}}}}
|
||||||
|
ds = await setup_datasette(config=config, databases=["content"])
|
||||||
|
|
||||||
|
assert not await ds.permission_allowed_2(
|
||||||
|
{"id": "alice"}, "view-table", ("content", "repos")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allow_sql_blocks():
|
||||||
|
config = {"allow_sql": {"id": "alice"}}
|
||||||
|
ds = await setup_datasette(config=config, databases=["content"])
|
||||||
|
|
||||||
|
assert await ds.permission_allowed_2({"id": "alice"}, "execute-sql", "content")
|
||||||
|
assert not await ds.permission_allowed_2({"id": "bob"}, "execute-sql", "content")
|
||||||
|
|
||||||
|
config = {"databases": {"content": {"allow_sql": {"id": "bob"}}}}
|
||||||
|
ds = await setup_datasette(config=config, databases=["content"])
|
||||||
|
|
||||||
|
assert await ds.permission_allowed_2({"id": "bob"}, "execute-sql", "content")
|
||||||
|
assert not await ds.permission_allowed_2({"id": "alice"}, "execute-sql", "content")
|
||||||
|
|
||||||
|
config = {"allow_sql": False}
|
||||||
|
ds = await setup_datasette(config=config, databases=["content"])
|
||||||
|
assert not await ds.permission_allowed_2({"id": "alice"}, "execute-sql", "content")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_view_instance_allow_block():
|
||||||
|
config = {"allow": {"id": "alice"}}
|
||||||
|
ds = await setup_datasette(config=config)
|
||||||
|
|
||||||
|
assert await ds.permission_allowed_2({"id": "alice"}, "view-instance")
|
||||||
|
assert not await ds.permission_allowed_2({"id": "bob"}, "view-instance")
|
||||||
495
tests/test_permission_endpoints.py
Normal file
495
tests/test_permission_endpoints.py
Normal file
|
|
@ -0,0 +1,495 @@
|
||||||
|
"""
|
||||||
|
Tests for permission inspection endpoints:
|
||||||
|
- /-/check.json
|
||||||
|
- /-/allowed.json
|
||||||
|
- /-/rules.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from datasette.app import Datasette
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def ds_with_permissions():
|
||||||
|
"""Create a Datasette instance with some permission rules configured."""
|
||||||
|
ds = Datasette(
|
||||||
|
config={
|
||||||
|
"databases": {
|
||||||
|
"content": {
|
||||||
|
"allow": {"id": "*"}, # Allow all authenticated users
|
||||||
|
"tables": {
|
||||||
|
"articles": {
|
||||||
|
"allow": {"id": "editor"}, # Only editor can view
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"private": {
|
||||||
|
"allow": False, # Deny everyone
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await ds.invoke_startup()
|
||||||
|
# Add some test databases
|
||||||
|
ds.add_memory_database("content")
|
||||||
|
ds.add_memory_database("private")
|
||||||
|
return ds
|
||||||
|
|
||||||
|
|
||||||
|
# /-/check.json tests
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path,expected_status,expected_keys",
|
||||||
|
[
|
||||||
|
# Valid request
|
||||||
|
(
|
||||||
|
"/-/check.json?action=view-instance",
|
||||||
|
200,
|
||||||
|
{"action", "allowed", "resource"},
|
||||||
|
),
|
||||||
|
# Missing action parameter
|
||||||
|
("/-/check.json", 400, {"error"}),
|
||||||
|
# Invalid action
|
||||||
|
("/-/check.json?action=nonexistent", 404, {"error"}),
|
||||||
|
# With parent parameter
|
||||||
|
(
|
||||||
|
"/-/check.json?action=view-database&parent=content",
|
||||||
|
200,
|
||||||
|
{"action", "allowed", "resource"},
|
||||||
|
),
|
||||||
|
# With parent and child parameters
|
||||||
|
(
|
||||||
|
"/-/check.json?action=view-table&parent=content&child=articles",
|
||||||
|
200,
|
||||||
|
{"action", "allowed", "resource"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_check_json_basic(
|
||||||
|
ds_with_permissions, path, expected_status, expected_keys
|
||||||
|
):
|
||||||
|
response = await ds_with_permissions.client.get(path)
|
||||||
|
assert response.status_code == expected_status
|
||||||
|
data = response.json()
|
||||||
|
assert expected_keys.issubset(data.keys())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_json_response_structure(ds_with_permissions):
|
||||||
|
"""Test that /-/check.json returns the expected structure."""
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/check.json?action=view-instance"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
assert "action" in data
|
||||||
|
assert "allowed" in data
|
||||||
|
assert "resource" in data
|
||||||
|
|
||||||
|
# Check resource structure
|
||||||
|
assert "parent" in data["resource"]
|
||||||
|
assert "child" in data["resource"]
|
||||||
|
assert "path" in data["resource"]
|
||||||
|
|
||||||
|
# Check allowed is boolean
|
||||||
|
assert isinstance(data["allowed"], bool)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_json_redacts_sensitive_fields_without_debug_permission(
|
||||||
|
ds_with_permissions,
|
||||||
|
):
|
||||||
|
"""Test that /-/check.json redacts reason and source_plugin without permissions-debug."""
|
||||||
|
# Anonymous user should not see sensitive fields
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/check.json?action=view-instance"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
# Sensitive fields should not be present
|
||||||
|
assert "reason" not in data
|
||||||
|
assert "source_plugin" not in data
|
||||||
|
# But these non-sensitive fields should be present
|
||||||
|
assert "used_default" in data
|
||||||
|
assert "depth" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_json_shows_sensitive_fields_with_debug_permission(
|
||||||
|
ds_with_permissions,
|
||||||
|
):
|
||||||
|
"""Test that /-/check.json shows reason and source_plugin with permissions-debug."""
|
||||||
|
# User with permissions-debug should see sensitive fields
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/check.json?action=view-instance",
|
||||||
|
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
# Sensitive fields should be present
|
||||||
|
assert "reason" in data
|
||||||
|
assert "source_plugin" in data
|
||||||
|
assert "used_default" in data
|
||||||
|
assert "depth" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_json_child_requires_parent(ds_with_permissions):
|
||||||
|
"""Test that child parameter requires parent parameter."""
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/check.json?action=view-table&child=articles"
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert "error" in data
|
||||||
|
assert "parent" in data["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# /-/allowed.json tests
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path,expected_status,expected_keys",
|
||||||
|
[
|
||||||
|
# Valid supported actions
|
||||||
|
(
|
||||||
|
"/-/allowed.json?action=view-instance",
|
||||||
|
200,
|
||||||
|
{"action", "items", "total", "page"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/-/allowed.json?action=view-database",
|
||||||
|
200,
|
||||||
|
{"action", "items", "total", "page"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/-/allowed.json?action=view-table",
|
||||||
|
200,
|
||||||
|
{"action", "items", "total", "page"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/-/allowed.json?action=execute-sql",
|
||||||
|
200,
|
||||||
|
{"action", "items", "total", "page"},
|
||||||
|
),
|
||||||
|
# Missing action parameter
|
||||||
|
("/-/allowed.json", 400, {"error"}),
|
||||||
|
# Invalid action
|
||||||
|
("/-/allowed.json?action=nonexistent", 404, {"error"}),
|
||||||
|
# Unsupported action (valid but not in CANDIDATE_SQL)
|
||||||
|
("/-/allowed.json?action=insert-row", 400, {"error"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_allowed_json_basic(
|
||||||
|
ds_with_permissions, path, expected_status, expected_keys
|
||||||
|
):
|
||||||
|
response = await ds_with_permissions.client.get(path)
|
||||||
|
assert response.status_code == expected_status
|
||||||
|
data = response.json()
|
||||||
|
assert expected_keys.issubset(data.keys())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allowed_json_response_structure(ds_with_permissions):
|
||||||
|
"""Test that /-/allowed.json returns the expected structure."""
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/allowed.json?action=view-instance"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
assert "action" in data
|
||||||
|
assert "actor_id" in data
|
||||||
|
assert "page" in data
|
||||||
|
assert "page_size" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "items" in data
|
||||||
|
|
||||||
|
# Check items structure
|
||||||
|
assert isinstance(data["items"], list)
|
||||||
|
if data["items"]:
|
||||||
|
item = data["items"][0]
|
||||||
|
assert "parent" in item
|
||||||
|
assert "child" in item
|
||||||
|
assert "resource" in item
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allowed_json_redacts_sensitive_fields_without_debug_permission(
|
||||||
|
ds_with_permissions,
|
||||||
|
):
|
||||||
|
"""Test that /-/allowed.json redacts reason and source_plugin without permissions-debug."""
|
||||||
|
# Anonymous user should not see sensitive fields
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/allowed.json?action=view-instance"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
if data["items"]:
|
||||||
|
item = data["items"][0]
|
||||||
|
assert "reason" not in item
|
||||||
|
assert "source_plugin" not in item
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allowed_json_shows_sensitive_fields_with_debug_permission(
|
||||||
|
ds_with_permissions,
|
||||||
|
):
|
||||||
|
"""Test that /-/allowed.json shows reason and source_plugin with permissions-debug."""
|
||||||
|
# User with permissions-debug should see sensitive fields
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/allowed.json?action=view-instance",
|
||||||
|
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
if data["items"]:
|
||||||
|
item = data["items"][0]
|
||||||
|
assert "reason" in item
|
||||||
|
assert "source_plugin" in item
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allowed_json_only_shows_allowed_resources(ds_with_permissions):
|
||||||
|
"""Test that /-/allowed.json only shows resources with allow=1."""
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/allowed.json?action=view-instance"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# All items should have allow implicitly set to 1 (not in response but verified by the endpoint logic)
|
||||||
|
# The endpoint filters to only show allowed resources
|
||||||
|
assert isinstance(data["items"], list)
|
||||||
|
assert data["total"] >= 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"page,page_size",
|
||||||
|
[
|
||||||
|
(1, 10),
|
||||||
|
(2, 50),
|
||||||
|
(1, 200), # max page size
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_allowed_json_pagination(ds_with_permissions, page, page_size):
|
||||||
|
"""Test pagination parameters."""
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
f"/-/allowed.json?action=view-instance&page={page}&page_size={page_size}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["page"] == page
|
||||||
|
assert data["page_size"] == min(page_size, 200) # Capped at 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"params,expected_status",
|
||||||
|
[
|
||||||
|
("page=0", 400), # page must be >= 1
|
||||||
|
("page=-1", 400),
|
||||||
|
("page_size=0", 400), # page_size must be >= 1
|
||||||
|
("page_size=-1", 400),
|
||||||
|
("page=abc", 400), # page must be integer
|
||||||
|
("page_size=xyz", 400), # page_size must be integer
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_allowed_json_pagination_errors(
|
||||||
|
ds_with_permissions, params, expected_status
|
||||||
|
):
|
||||||
|
"""Test pagination error handling."""
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
f"/-/allowed.json?action=view-instance&{params}"
|
||||||
|
)
|
||||||
|
assert response.status_code == expected_status
|
||||||
|
|
||||||
|
|
||||||
|
# /-/rules.json tests
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rules_json_requires_permissions_debug(ds_with_permissions):
|
||||||
|
"""Test that /-/rules.json requires permissions-debug permission."""
|
||||||
|
# Anonymous user should be denied
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/rules.json?action=view-instance"
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
# Regular authenticated user should also be denied
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/rules.json?action=view-instance",
|
||||||
|
cookies={
|
||||||
|
"ds_actor": ds_with_permissions.client.actor_cookie({"id": "regular-user"})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
# User with permissions-debug should be allowed
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/rules.json?action=view-instance",
|
||||||
|
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path,expected_status,expected_keys",
|
||||||
|
[
|
||||||
|
# Valid request
|
||||||
|
(
|
||||||
|
"/-/rules.json?action=view-instance",
|
||||||
|
200,
|
||||||
|
{"action", "items", "total", "page"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/-/rules.json?action=view-database",
|
||||||
|
200,
|
||||||
|
{"action", "items", "total", "page"},
|
||||||
|
),
|
||||||
|
# Missing action parameter
|
||||||
|
("/-/rules.json", 400, {"error"}),
|
||||||
|
# Invalid action
|
||||||
|
("/-/rules.json?action=nonexistent", 404, {"error"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_rules_json_basic(
|
||||||
|
ds_with_permissions, path, expected_status, expected_keys
|
||||||
|
):
|
||||||
|
# Use debugger user who has permissions-debug
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
path,
|
||||||
|
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
|
||||||
|
)
|
||||||
|
assert response.status_code == expected_status
|
||||||
|
data = response.json()
|
||||||
|
assert expected_keys.issubset(data.keys())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rules_json_response_structure(ds_with_permissions):
|
||||||
|
"""Test that /-/rules.json returns the expected structure."""
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/rules.json?action=view-instance",
|
||||||
|
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
assert "action" in data
|
||||||
|
assert "actor_id" in data
|
||||||
|
assert "page" in data
|
||||||
|
assert "page_size" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "items" in data
|
||||||
|
|
||||||
|
# Check items structure
|
||||||
|
assert isinstance(data["items"], list)
|
||||||
|
if data["items"]:
|
||||||
|
item = data["items"][0]
|
||||||
|
assert "parent" in item
|
||||||
|
assert "child" in item
|
||||||
|
assert "resource" in item
|
||||||
|
assert "allow" in item # Important: should include allow field
|
||||||
|
assert "reason" in item
|
||||||
|
assert "source_plugin" in item
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rules_json_includes_both_allow_and_deny(ds_with_permissions):
|
||||||
|
"""Test that /-/rules.json includes both allow and deny rules."""
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
"/-/rules.json?action=view-database",
|
||||||
|
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Check that items have the allow field
|
||||||
|
assert isinstance(data["items"], list)
|
||||||
|
if data["items"]:
|
||||||
|
# Verify allow field exists and is 0 or 1
|
||||||
|
for item in data["items"]:
|
||||||
|
assert "allow" in item
|
||||||
|
assert item["allow"] in (0, 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"page,page_size",
|
||||||
|
[
|
||||||
|
(1, 10),
|
||||||
|
(2, 50),
|
||||||
|
(1, 200), # max page size
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_rules_json_pagination(ds_with_permissions, page, page_size):
|
||||||
|
"""Test pagination parameters."""
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
f"/-/rules.json?action=view-instance&page={page}&page_size={page_size}",
|
||||||
|
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["page"] == page
|
||||||
|
assert data["page_size"] == min(page_size, 200) # Capped at 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"params,expected_status",
|
||||||
|
[
|
||||||
|
("page=0", 400), # page must be >= 1
|
||||||
|
("page=-1", 400),
|
||||||
|
("page_size=0", 400), # page_size must be >= 1
|
||||||
|
("page_size=-1", 400),
|
||||||
|
("page=abc", 400), # page must be integer
|
||||||
|
("page_size=xyz", 400), # page_size must be integer
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_rules_json_pagination_errors(
|
||||||
|
ds_with_permissions, params, expected_status
|
||||||
|
):
|
||||||
|
"""Test pagination error handling."""
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
f"/-/rules.json?action=view-instance&{params}",
|
||||||
|
cookies={"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})},
|
||||||
|
)
|
||||||
|
assert response.status_code == expected_status
|
||||||
|
|
||||||
|
|
||||||
|
# Test that HTML endpoints return HTML (not JSON) when accessed without .json
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path,needs_debug",
|
||||||
|
[
|
||||||
|
("/-/check", False),
|
||||||
|
("/-/check?action=view-instance", False),
|
||||||
|
("/-/allowed", False),
|
||||||
|
("/-/allowed?action=view-instance", False),
|
||||||
|
("/-/rules", True),
|
||||||
|
("/-/rules?action=view-instance", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_html_endpoints_return_html(ds_with_permissions, path, needs_debug):
|
||||||
|
"""Test that endpoints without .json extension return HTML."""
|
||||||
|
if needs_debug:
|
||||||
|
# Rules endpoint requires permissions-debug
|
||||||
|
response = await ds_with_permissions.client.get(
|
||||||
|
path,
|
||||||
|
cookies={
|
||||||
|
"ds_actor": ds_with_permissions.client.actor_cookie({"id": "root"})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = await ds_with_permissions.client.get(path)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
# Check for HTML structure
|
||||||
|
text = response.text
|
||||||
|
assert "<!DOCTYPE html>" in text or "<html" in text
|
||||||
|
|
@ -12,8 +12,9 @@ from datasette.app import Datasette
|
||||||
from datasette import cli, hookimpl, Permission
|
from datasette import cli, hookimpl, Permission
|
||||||
from datasette.filters import FilterArguments
|
from datasette.filters import FilterArguments
|
||||||
from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm
|
from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm
|
||||||
|
from datasette.utils.permissions import PluginSQL
|
||||||
from datasette.utils.sqlite import sqlite3
|
from datasette.utils.sqlite import sqlite3
|
||||||
from datasette.utils import StartupError
|
from datasette.utils import StartupError, await_me_maybe
|
||||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||||
import base64
|
import base64
|
||||||
import datetime
|
import datetime
|
||||||
|
|
@ -701,6 +702,29 @@ async def test_hook_permission_allowed(action, expected):
|
||||||
pm.unregister(name="undo_register_extras")
|
pm.unregister(name="undo_register_extras")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_permission_resources_sql():
|
||||||
|
ds = Datasette()
|
||||||
|
await ds.invoke_startup()
|
||||||
|
|
||||||
|
collected = []
|
||||||
|
for block in pm.hook.permission_resources_sql(
|
||||||
|
datasette=ds,
|
||||||
|
actor={"id": "alice"},
|
||||||
|
action="view-table",
|
||||||
|
):
|
||||||
|
block = await await_me_maybe(block)
|
||||||
|
if block is None:
|
||||||
|
continue
|
||||||
|
if isinstance(block, (list, tuple)):
|
||||||
|
collected.extend(block)
|
||||||
|
else:
|
||||||
|
collected.append(block)
|
||||||
|
|
||||||
|
assert collected
|
||||||
|
assert all(isinstance(item, PluginSQL) for item in collected)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_actor_json(ds_client):
|
async def test_actor_json(ds_client):
|
||||||
assert (await ds_client.get("/-/actor.json")).json() == {"actor": None}
|
assert (await ds_client.get("/-/actor.json")).json() == {"actor": None}
|
||||||
|
|
|
||||||
440
tests/test_utils_permissions.py
Normal file
440
tests/test_utils_permissions.py
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
import pytest
|
||||||
|
from datasette.app import Datasette
|
||||||
|
from datasette.utils.permissions import (
|
||||||
|
PluginSQL,
|
||||||
|
PluginProvider,
|
||||||
|
resolve_permissions_from_catalog,
|
||||||
|
)
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db():
|
||||||
|
ds = Datasette()
|
||||||
|
import tempfile
|
||||||
|
from datasette.database import Database
|
||||||
|
|
||||||
|
path = tempfile.mktemp(suffix="demo.db")
|
||||||
|
db = ds.add_database(Database(ds, path=path))
|
||||||
|
print(path)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
NO_RULES_SQL = (
|
||||||
|
"SELECT NULL AS parent, NULL AS child, NULL AS allow, NULL AS reason WHERE 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_allow_all_for_user(user: str) -> PluginProvider:
|
||||||
|
def provider(action: str) -> PluginSQL:
|
||||||
|
return PluginSQL(
|
||||||
|
"allow_all",
|
||||||
|
"""
|
||||||
|
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
||||||
|
'global allow for ' || :user || ' on ' || :action AS reason
|
||||||
|
WHERE :actor = :user
|
||||||
|
""",
|
||||||
|
{"user": user, "action": action},
|
||||||
|
)
|
||||||
|
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_deny_specific_table(user: str, parent: str, child: str) -> PluginProvider:
|
||||||
|
def provider(action: str) -> PluginSQL:
|
||||||
|
return PluginSQL(
|
||||||
|
"deny_specific_table",
|
||||||
|
"""
|
||||||
|
SELECT :parent AS parent, :child AS child, 0 AS allow,
|
||||||
|
'deny ' || :parent || '/' || :child || ' for ' || :user || ' on ' || :action AS reason
|
||||||
|
WHERE :actor = :user
|
||||||
|
""",
|
||||||
|
{"parent": parent, "child": child, "user": user, "action": action},
|
||||||
|
)
|
||||||
|
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_org_policy_deny_parent(parent: str) -> PluginProvider:
|
||||||
|
def provider(action: str) -> PluginSQL:
|
||||||
|
return PluginSQL(
|
||||||
|
"org_policy_parent_deny",
|
||||||
|
"""
|
||||||
|
SELECT :parent AS parent, NULL AS child, 0 AS allow,
|
||||||
|
'org policy: parent ' || :parent || ' denied on ' || :action AS reason
|
||||||
|
""",
|
||||||
|
{"parent": parent, "action": action},
|
||||||
|
)
|
||||||
|
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_allow_parent_for_user(user: str, parent: str) -> PluginProvider:
|
||||||
|
def provider(action: str) -> PluginSQL:
|
||||||
|
return PluginSQL(
|
||||||
|
"allow_parent",
|
||||||
|
"""
|
||||||
|
SELECT :parent AS parent, NULL AS child, 1 AS allow,
|
||||||
|
'allow full parent for ' || :user || ' on ' || :action AS reason
|
||||||
|
WHERE :actor = :user
|
||||||
|
""",
|
||||||
|
{"parent": parent, "user": user, "action": action},
|
||||||
|
)
|
||||||
|
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_child_allow_for_user(user: str, parent: str, child: str) -> PluginProvider:
|
||||||
|
def provider(action: str) -> PluginSQL:
|
||||||
|
return PluginSQL(
|
||||||
|
"allow_child",
|
||||||
|
"""
|
||||||
|
SELECT :parent AS parent, :child AS child, 1 AS allow,
|
||||||
|
'allow child for ' || :user || ' on ' || :action AS reason
|
||||||
|
WHERE :actor = :user
|
||||||
|
""",
|
||||||
|
{"parent": parent, "child": child, "user": user, "action": action},
|
||||||
|
)
|
||||||
|
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_root_deny_for_all() -> PluginProvider:
|
||||||
|
def provider(action: str) -> PluginSQL:
|
||||||
|
return PluginSQL(
|
||||||
|
"root_deny",
|
||||||
|
"""
|
||||||
|
SELECT NULL AS parent, NULL AS child, 0 AS allow, 'root deny for all on ' || :action AS reason
|
||||||
|
""",
|
||||||
|
{"action": action},
|
||||||
|
)
|
||||||
|
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_conflicting_same_child_rules(
|
||||||
|
user: str, parent: str, child: str
|
||||||
|
) -> List[PluginProvider]:
|
||||||
|
def allow_provider(action: str) -> PluginSQL:
|
||||||
|
return PluginSQL(
|
||||||
|
"conflict_child_allow",
|
||||||
|
"""
|
||||||
|
SELECT :parent AS parent, :child AS child, 1 AS allow,
|
||||||
|
'team grant at child for ' || :user || ' on ' || :action AS reason
|
||||||
|
WHERE :actor = :user
|
||||||
|
""",
|
||||||
|
{"parent": parent, "child": child, "user": user, "action": action},
|
||||||
|
)
|
||||||
|
|
||||||
|
def deny_provider(action: str) -> PluginSQL:
|
||||||
|
return PluginSQL(
|
||||||
|
"conflict_child_deny",
|
||||||
|
"""
|
||||||
|
SELECT :parent AS parent, :child AS child, 0 AS allow,
|
||||||
|
'exception deny at child for ' || :user || ' on ' || :action AS reason
|
||||||
|
WHERE :actor = :user
|
||||||
|
""",
|
||||||
|
{"parent": parent, "child": child, "user": user, "action": action},
|
||||||
|
)
|
||||||
|
|
||||||
|
return [allow_provider, deny_provider]
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_allow_all_for_action(user: str, allowed_action: str) -> PluginProvider:
|
||||||
|
def provider(action: str) -> PluginSQL:
|
||||||
|
if action != allowed_action:
|
||||||
|
return PluginSQL(
|
||||||
|
f"allow_all_{allowed_action}_noop",
|
||||||
|
NO_RULES_SQL,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
return PluginSQL(
|
||||||
|
f"allow_all_{allowed_action}",
|
||||||
|
"""
|
||||||
|
SELECT NULL AS parent, NULL AS child, 1 AS allow,
|
||||||
|
'global allow for ' || :user || ' on ' || :action AS reason
|
||||||
|
WHERE :actor = :user
|
||||||
|
""",
|
||||||
|
{"user": user, "action": action},
|
||||||
|
)
|
||||||
|
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
VIEW_TABLE = "view-table"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Catalog DDL (from your schema) ----------
|
||||||
|
CATALOG_DDL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_databases (
|
||||||
|
database_name TEXT PRIMARY KEY,
|
||||||
|
path TEXT,
|
||||||
|
is_memory INTEGER,
|
||||||
|
schema_version INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_tables (
|
||||||
|
database_name TEXT,
|
||||||
|
table_name TEXT,
|
||||||
|
rootpage INTEGER,
|
||||||
|
sql TEXT,
|
||||||
|
PRIMARY KEY (database_name, table_name),
|
||||||
|
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
PARENTS = ["accounting", "hr", "analytics"]
|
||||||
|
SPECIALS = {"accounting": ["sales"], "analytics": ["secret"], "hr": []}
|
||||||
|
|
||||||
|
TABLE_CANDIDATES_SQL = (
|
||||||
|
"SELECT database_name AS parent, table_name AS child FROM catalog_tables"
|
||||||
|
)
|
||||||
|
PARENT_CANDIDATES_SQL = (
|
||||||
|
"SELECT database_name AS parent, NULL AS child FROM catalog_databases"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Helpers ----------
|
||||||
|
async def seed_catalog(db, per_parent: int = 10) -> None:
|
||||||
|
await db.execute_write_script(CATALOG_DDL)
|
||||||
|
# databases
|
||||||
|
db_rows = [(p, f"/{p}.db", 0, 1) for p in PARENTS]
|
||||||
|
await db.execute_write_many(
|
||||||
|
"INSERT OR REPLACE INTO catalog_databases(database_name, path, is_memory, schema_version) VALUES (?,?,?,?)",
|
||||||
|
db_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
# tables
|
||||||
|
def tables_for(parent: str, n: int):
|
||||||
|
base = [f"table{i:02d}" for i in range(1, n + 1)]
|
||||||
|
for s in SPECIALS.get(parent, []):
|
||||||
|
if s not in base:
|
||||||
|
base[0] = s
|
||||||
|
return base
|
||||||
|
|
||||||
|
table_rows = []
|
||||||
|
for p in PARENTS:
|
||||||
|
for t in tables_for(p, per_parent):
|
||||||
|
table_rows.append((p, t, 0, f"CREATE TABLE {t} (id INTEGER PRIMARY KEY)"))
|
||||||
|
await db.execute_write_many(
|
||||||
|
"INSERT OR REPLACE INTO catalog_tables(database_name, table_name, rootpage, sql) VALUES (?,?,?,?)",
|
||||||
|
table_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def res_allowed(rows, parent=None):
|
||||||
|
return sorted(
|
||||||
|
r["resource"]
|
||||||
|
for r in rows
|
||||||
|
if r["allow"] == 1 and (parent is None or r["parent"] == parent)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def res_denied(rows, parent=None):
|
||||||
|
return sorted(
|
||||||
|
r["resource"]
|
||||||
|
for r in rows
|
||||||
|
if r["allow"] == 0 and (parent is None or r["parent"] == parent)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Tests ----------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_alice_global_allow_with_specific_denies_catalog(db):
|
||||||
|
await seed_catalog(db)
|
||||||
|
plugins = [
|
||||||
|
plugin_allow_all_for_user("alice"),
|
||||||
|
plugin_deny_specific_table("alice", "accounting", "sales"),
|
||||||
|
plugin_org_policy_deny_parent("hr"),
|
||||||
|
]
|
||||||
|
rows = await resolve_permissions_from_catalog(
|
||||||
|
db, "alice", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||||
|
)
|
||||||
|
# Alice can see everything except accounting/sales and hr/*
|
||||||
|
assert "/accounting/sales" in res_denied(rows)
|
||||||
|
for r in rows:
|
||||||
|
if r["parent"] == "hr":
|
||||||
|
assert r["allow"] == 0
|
||||||
|
elif r["resource"] == "/accounting/sales":
|
||||||
|
assert r["allow"] == 0
|
||||||
|
else:
|
||||||
|
assert r["allow"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_carol_parent_allow_but_child_conflict_deny_wins_catalog(db):
|
||||||
|
await seed_catalog(db)
|
||||||
|
plugins = [
|
||||||
|
plugin_org_policy_deny_parent("hr"),
|
||||||
|
plugin_allow_parent_for_user("carol", "analytics"),
|
||||||
|
*plugin_conflicting_same_child_rules("carol", "analytics", "secret"),
|
||||||
|
]
|
||||||
|
rows = await resolve_permissions_from_catalog(
|
||||||
|
db, "carol", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||||
|
)
|
||||||
|
allowed_analytics = res_allowed(rows, parent="analytics")
|
||||||
|
denied_analytics = res_denied(rows, parent="analytics")
|
||||||
|
|
||||||
|
assert "/analytics/secret" in denied_analytics
|
||||||
|
# 10 analytics children total, 1 denied
|
||||||
|
assert len(allowed_analytics) == 9
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_specificity_child_allow_overrides_parent_deny_catalog(db):
|
||||||
|
await seed_catalog(db)
|
||||||
|
plugins = [
|
||||||
|
plugin_allow_all_for_user("alice"),
|
||||||
|
plugin_org_policy_deny_parent("analytics"), # parent-level deny
|
||||||
|
plugin_child_allow_for_user(
|
||||||
|
"alice", "analytics", "table02"
|
||||||
|
), # child allow beats parent deny
|
||||||
|
]
|
||||||
|
rows = await resolve_permissions_from_catalog(
|
||||||
|
db, "alice", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# table02 allowed, other analytics tables denied
|
||||||
|
assert any(r["resource"] == "/analytics/table02" and r["allow"] == 1 for r in rows)
|
||||||
|
assert all(
|
||||||
|
(r["parent"] != "analytics" or r["child"] == "table02" or r["allow"] == 0)
|
||||||
|
for r in rows
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_root_deny_all_but_parent_allow_rescues_specific_parent_catalog(db):
|
||||||
|
await seed_catalog(db)
|
||||||
|
plugins = [
|
||||||
|
plugin_root_deny_for_all(), # root deny
|
||||||
|
plugin_allow_parent_for_user(
|
||||||
|
"bob", "accounting"
|
||||||
|
), # parent allow (more specific)
|
||||||
|
]
|
||||||
|
rows = await resolve_permissions_from_catalog(
|
||||||
|
db, "bob", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
if r["parent"] == "accounting":
|
||||||
|
assert r["allow"] == 1
|
||||||
|
else:
|
||||||
|
assert r["allow"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parent_scoped_candidates(db):
|
||||||
|
await seed_catalog(db)
|
||||||
|
plugins = [
|
||||||
|
plugin_org_policy_deny_parent("hr"),
|
||||||
|
plugin_allow_parent_for_user("carol", "analytics"),
|
||||||
|
]
|
||||||
|
rows = await resolve_permissions_from_catalog(
|
||||||
|
db, "carol", plugins, VIEW_TABLE, PARENT_CANDIDATES_SQL, implicit_deny=True
|
||||||
|
)
|
||||||
|
d = {r["resource"]: r["allow"] for r in rows}
|
||||||
|
assert d["/analytics"] == 1
|
||||||
|
assert d["/hr"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_implicit_deny_behavior(db):
|
||||||
|
await seed_catalog(db)
|
||||||
|
plugins = [] # no rules at all
|
||||||
|
|
||||||
|
# implicit_deny=True -> everything denied with reason 'implicit deny'
|
||||||
|
rows = await resolve_permissions_from_catalog(
|
||||||
|
db, "erin", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=True
|
||||||
|
)
|
||||||
|
assert all(r["allow"] == 0 and r["reason"] == "implicit deny" for r in rows)
|
||||||
|
|
||||||
|
# implicit_deny=False -> no winner => allow is None, reason is None
|
||||||
|
rows2 = await resolve_permissions_from_catalog(
|
||||||
|
db, "erin", plugins, VIEW_TABLE, TABLE_CANDIDATES_SQL, implicit_deny=False
|
||||||
|
)
|
||||||
|
assert all(r["allow"] is None and r["reason"] is None for r in rows2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_candidate_filters_via_params(db):
|
||||||
|
await seed_catalog(db)
|
||||||
|
# Add some metadata to test filtering
|
||||||
|
# Mark 'hr' as is_memory=1 and increment analytics schema_version
|
||||||
|
await db.execute_write(
|
||||||
|
"UPDATE catalog_databases SET is_memory=1 WHERE database_name='hr'"
|
||||||
|
)
|
||||||
|
await db.execute_write(
|
||||||
|
"UPDATE catalog_databases SET schema_version=2 WHERE database_name='analytics'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Candidate SQL that filters by db metadata via params
|
||||||
|
candidate_sql = """
|
||||||
|
SELECT t.database_name AS parent, t.table_name AS child
|
||||||
|
FROM catalog_tables t
|
||||||
|
JOIN catalog_databases d ON d.database_name = t.database_name
|
||||||
|
WHERE (:exclude_memory = 1 AND d.is_memory = 1) IS NOT 1
|
||||||
|
AND (:min_schema_version IS NULL OR d.schema_version >= :min_schema_version)
|
||||||
|
"""
|
||||||
|
|
||||||
|
plugins = [
|
||||||
|
plugin_root_deny_for_all(),
|
||||||
|
plugin_allow_parent_for_user(
|
||||||
|
"dev", "analytics"
|
||||||
|
), # analytics rescued if included by candidates
|
||||||
|
]
|
||||||
|
|
||||||
|
# Case 1: exclude memory dbs, require schema_version >= 2 -> only analytics appear, and thus are allowed
|
||||||
|
rows = await resolve_permissions_from_catalog(
|
||||||
|
db,
|
||||||
|
"dev",
|
||||||
|
plugins,
|
||||||
|
VIEW_TABLE,
|
||||||
|
candidate_sql,
|
||||||
|
candidate_params={"exclude_memory": 1, "min_schema_version": 2},
|
||||||
|
implicit_deny=True,
|
||||||
|
)
|
||||||
|
assert rows and all(r["parent"] == "analytics" for r in rows)
|
||||||
|
assert all(r["allow"] == 1 for r in rows)
|
||||||
|
|
||||||
|
# Case 2: include memory dbs, min_schema_version = None -> accounting/hr/analytics appear,
|
||||||
|
# but root deny wins except where specifically allowed (none except analytics parent allow doesn’t apply to table depth if candidate includes children; still fine—policy is explicit).
|
||||||
|
rows2 = await resolve_permissions_from_catalog(
|
||||||
|
db,
|
||||||
|
"dev",
|
||||||
|
plugins,
|
||||||
|
VIEW_TABLE,
|
||||||
|
candidate_sql,
|
||||||
|
candidate_params={"exclude_memory": 0, "min_schema_version": None},
|
||||||
|
implicit_deny=True,
|
||||||
|
)
|
||||||
|
assert any(r["parent"] == "accounting" for r in rows2)
|
||||||
|
assert any(r["parent"] == "hr" for r in rows2)
|
||||||
|
# For table-scoped candidates, the parent-level allow does not override root deny unless you have child-level rules
|
||||||
|
assert all(r["allow"] in (0, 1) for r in rows2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_specific_rules(db):
|
||||||
|
await seed_catalog(db)
|
||||||
|
plugins = [plugin_allow_all_for_action("dana", VIEW_TABLE)]
|
||||||
|
|
||||||
|
view_rows = await resolve_permissions_from_catalog(
|
||||||
|
db,
|
||||||
|
"dana",
|
||||||
|
plugins,
|
||||||
|
VIEW_TABLE,
|
||||||
|
TABLE_CANDIDATES_SQL,
|
||||||
|
implicit_deny=True,
|
||||||
|
)
|
||||||
|
assert view_rows and all(r["allow"] == 1 for r in view_rows)
|
||||||
|
assert all(r["action"] == VIEW_TABLE for r in view_rows)
|
||||||
|
|
||||||
|
insert_rows = await resolve_permissions_from_catalog(
|
||||||
|
db,
|
||||||
|
"dana",
|
||||||
|
plugins,
|
||||||
|
"insert-row",
|
||||||
|
TABLE_CANDIDATES_SQL,
|
||||||
|
implicit_deny=True,
|
||||||
|
)
|
||||||
|
assert insert_rows and all(r["allow"] == 0 for r in insert_rows)
|
||||||
|
assert all(r["reason"] == "implicit deny" for r in insert_rows)
|
||||||
|
assert all(r["action"] == "insert-row" for r in insert_rows)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue