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,
|
||||
PermissionsDebugView,
|
||||
MessagesDebugView,
|
||||
AllowedResourcesView,
|
||||
PermissionRulesView,
|
||||
PermissionCheckView,
|
||||
)
|
||||
from .views.table import (
|
||||
TableInsertView,
|
||||
|
|
@ -111,6 +114,8 @@ from .tracer import AsgiTracer
|
|||
from .plugins import pm, DEFAULT_PLUGINS, get_plugins
|
||||
from .version import __version__
|
||||
|
||||
from .utils.permissions import build_rules_union, PluginSQL
|
||||
|
||||
app_root = Path(__file__).parent.parent
|
||||
|
||||
# https://github.com/simonw/datasette/issues/283#issuecomment-781591015
|
||||
|
|
@ -1030,6 +1035,149 @@ class Datasette:
|
|||
)
|
||||
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(
|
||||
self,
|
||||
actor: dict,
|
||||
|
|
@ -1586,6 +1734,18 @@ class Datasette:
|
|||
PermissionsDebugView.as_view(self),
|
||||
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(
|
||||
MessagesDebugView.as_view(self),
|
||||
r"/-/messages$",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from datasette import hookimpl, Permission
|
||||
from datasette.utils.permissions import PluginSQL
|
||||
from datasette.utils import actor_matches_allow
|
||||
import itsdangerous
|
||||
import time
|
||||
from typing import Union, Tuple
|
||||
|
||||
|
||||
@hookimpl
|
||||
|
|
@ -172,6 +172,163 @@ def permission_allowed_default(datasette, actor, action, resource):
|
|||
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):
|
||||
# Check custom permissions: blocks
|
||||
config = datasette.config or {}
|
||||
|
|
@ -277,7 +434,7 @@ def restrictions_allow_action(
|
|||
datasette: "Datasette",
|
||||
restrictions: dict,
|
||||
action: str,
|
||||
resource: Union[str, Tuple[str, str]],
|
||||
resource: str | tuple[str, str],
|
||||
):
|
||||
"Do these restrictions allow the requested action against the requested resource?"
|
||||
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"""
|
||||
|
||||
|
||||
@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
|
||||
def canned_queries(datasette, database, actor):
|
||||
"""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 csv
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
|
|
@ -173,6 +174,24 @@ class BaseView:
|
|||
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
|
||||
def as_view(cls, *class_args, **class_kwargs):
|
||||
async def view(request, send):
|
||||
|
|
|
|||
|
|
@ -1,17 +1,32 @@
|
|||
import json
|
||||
import logging
|
||||
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
||||
from datasette.utils.asgi import Response, Forbidden
|
||||
from datasette.utils import (
|
||||
actor_matches_allow,
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
tilde_encode,
|
||||
tilde_decode,
|
||||
)
|
||||
from datasette.utils.permissions import PluginSQL, resolve_permissions_from_catalog
|
||||
from datasette.plugins import pm
|
||||
from .base import BaseView, View
|
||||
import secrets
|
||||
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):
|
||||
name = "json_data"
|
||||
|
||||
|
|
@ -30,32 +45,13 @@ class JsonDataView(BaseView):
|
|||
self.permission = permission
|
||||
|
||||
async def get(self, request):
|
||||
as_format = request.url_vars["format"]
|
||||
if self.permission:
|
||||
await self.ds.ensure_permissions(request.actor, [self.permission])
|
||||
if self.needs_request:
|
||||
data = self.data_callback(request)
|
||||
else:
|
||||
data = self.data_callback()
|
||||
if as_format:
|
||||
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),
|
||||
},
|
||||
)
|
||||
return await self.respond_json_or_html(request, data, self.filename)
|
||||
|
||||
|
||||
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):
|
||||
name = "allow_debug"
|
||||
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.
|
||||
|
||||
.. _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:
|
||||
|
||||
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.
|
||||
* **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 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -1290,12 +1290,13 @@ Here's an example that allows users to view the ``admin_log`` table only if thei
|
|||
if not actor:
|
||||
return False
|
||||
user_id = actor["id"]
|
||||
return await datasette.get_database(
|
||||
result = await datasette.get_database(
|
||||
"staff"
|
||||
).execute(
|
||||
"select count(*) from admin_users where user_id = :user_id",
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return result.first()[0] > 0
|
||||
|
||||
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>`_
|
||||
|
||||
.. _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:
|
||||
|
||||
register_magic_parameters(datasette)
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
|
|||
"hooks": [
|
||||
"actor_from_request",
|
||||
"permission_allowed",
|
||||
"permission_resources_sql",
|
||||
"register_permissions",
|
||||
"skip_csrf"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,5 +6,4 @@ filterwarnings=
|
|||
ignore:Using or importing the ABCs::bs4.element
|
||||
markers =
|
||||
serial: tests to avoid using with pytest-xdist
|
||||
asyncio_mode = strict
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
asyncio_mode = strict
|
||||
2
setup.py
2
setup.py
|
|
@ -80,7 +80,7 @@ setup(
|
|||
"test": [
|
||||
"pytest>=5.2.2",
|
||||
"pytest-xdist>=2.2.1",
|
||||
"pytest-asyncio>=0.17",
|
||||
"pytest-asyncio>=1.2.0",
|
||||
"beautifulsoup4>=4.8.1",
|
||||
"black==25.1.0",
|
||||
"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.filters import FilterArguments
|
||||
from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm
|
||||
from datasette.utils.permissions import PluginSQL
|
||||
from datasette.utils.sqlite import sqlite3
|
||||
from datasette.utils import StartupError
|
||||
from datasette.utils import StartupError, await_me_maybe
|
||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
import base64
|
||||
import datetime
|
||||
|
|
@ -701,6 +702,29 @@ async def test_hook_permission_allowed(action, expected):
|
|||
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
|
||||
async def test_actor_json(ds_client):
|
||||
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