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:
Simon Willison 2025-10-08 14:27:51 -07:00 committed by GitHub
commit 27084caa04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 3381 additions and 27 deletions

View file

@ -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$",

View file

@ -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":

View file

@ -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"""

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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,
)

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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"
]

View file

@ -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

View file

@ -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",

View 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")

View 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

View file

@ -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}

View 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 doesnt 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)