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