Compare commits

..

8 commits

Author SHA1 Message Date
Simon Willison
481df7ff6d Shorten link text in changelog 2026-07-14 09:31:28 -07:00
Simon Willison
2ffd8a860e Release 1.0a37
Refs #2831, #2832, #2841, #2842, #2843, #2846
2026-07-14 09:28:29 -07:00
Simon Willison
8b7c942d5e Major performance boost for SQL permissions, closes #2832 2026-07-14 09:18:51 -07:00
TowyTowy
591b909a4d
Escape table names with [square] brackets, refs #2431 (#2846)
Several internal helpers quoted table names using SQLite [bracket]
identifiers built with an f-string, e.g. PRAGMA foreign_key_list([{table}]).
Bracket quoting cannot escape a "]" character, so any table whose name
contains "]" (for example "[foo]" or "foo]") produced
"sqlite3.OperationalError: unrecognized token" - crashing schema
introspection at startup and 500-ing the table page.

Switch these call sites to the existing escape_sqlite() helper, which uses
"double quote" quoting with correct "" escaping (the same approach already
used elsewhere in the codebase and in the test suite):

- utils/internal_db.py: PRAGMA foreign_key_list / index_list
- utils/__init__.py: get_outbound_foreign_keys
- database.py: table_counts count query
- facets.py: default "select * from" SQL

Added a regression test covering table names with "]" characters.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-14 08:53:45 -07:00
Simon Willison
9cfc252394
Make internal catalog refresh atomic
Refs #2831
2026-07-14 08:41:27 -07:00
Simon Willison
7f0a8b38ae
Better permission debug tools and documentation
Closes #2841
2026-07-14 08:40:07 -07:00
Simon Willison
10088dfa1d
execute_write(transaction=False) parameter, plus fix for errors inside tasks
Ensure a write inside a failing Datasette task never becomes visible. Refs #2831
2026-07-13 22:42:44 -07:00
Simon Willison
ccace40e5a
/-/plugins.json is now an array of objects again (#2843)
Reverts the object envelope introduced in 1.0a36 for this endpoint -
it once again returns a top-level JSON array of plugin objects.

Closes #2842


Claude-Session: https://claude.ai/code/session_012TYc1NTBK4zEjabB3u2zqu

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-13 21:19:04 -07:00
44 changed files with 1763 additions and 1270 deletions

View file

@ -85,7 +85,6 @@ from .views.special import (
JumpView,
InstanceSchemaView,
DatabaseSchemaView,
DatabaseEditorSchemaView,
TableSchemaView,
)
from .views.table import (
@ -754,19 +753,7 @@ class Datasette:
# Compare schema versions to see if we should skip it
if schema_version == current_schema_versions.get(database_name):
continue
placeholders = "(?, ?, ?, ?)"
values = [database_name, str(db.path), db.is_memory, schema_version]
if db.path is None:
placeholders = "(?, null, ?, ?)"
values = [database_name, db.is_memory, schema_version]
await internal_db.execute_write(
"""
INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version)
VALUES {}
""".format(placeholders),
values,
)
await populate_schema_tables(internal_db, db)
await populate_schema_tables(internal_db, db, schema_version)
@property
def urls(self):
@ -2576,7 +2563,7 @@ class Datasette:
JsonDataView.as_view(
self,
"plugins.json",
lambda request: {"plugins": self._plugins(request)},
self._plugins,
needs_request=True,
),
r"/-/plugins(\.(?P<format>json))?$",
@ -2717,10 +2704,6 @@ class Datasette:
DatabaseSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
)
add_route(
DatabaseEditorSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/editor-schema\.json$",
)
add_route(
QueryParametersView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/query/parameters$",

View file

@ -17,6 +17,7 @@ from .utils import (
detect_fts,
detect_primary_keys,
detect_spatialite,
escape_sqlite,
get_all_foreign_keys,
get_outbound_foreign_keys,
md5_not_usedforsecurity,
@ -246,6 +247,7 @@ class Database:
request=None,
return_all=False,
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
transaction=True,
):
self._check_not_closed()
if returning_limit < 0:
@ -258,7 +260,9 @@ class Database:
)
with trace("sql", database=self.name, sql=sql.strip(), params=params):
results = await self.execute_write_fn(_inner, block=block, request=request)
results = await self.execute_write_fn(
_inner, block=block, request=request, transaction=transaction
)
return results
async def execute_write_script(self, sql, block=True, request=None):
@ -348,6 +352,7 @@ class Database:
self.ds._prepare_connection(self._write_connection, self.name)
if transaction:
with self._write_connection:
self._write_connection.execute("BEGIN IMMEDIATE")
result = fn(self._write_connection)
else:
result = fn(self._write_connection)
@ -477,6 +482,7 @@ class Database:
try:
if task.transaction:
with conn:
conn.execute("BEGIN IMMEDIATE")
result = task.fn(conn)
else:
result = task.fn(conn)
@ -603,7 +609,7 @@ class Database:
try:
table_count = (
await self.execute(
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
f"select count(*) from (select * from {escape_sqlite(table)} limit {self.count_limit + 1})",
custom_time_limit=limit,
)
).rows[0][0]

View file

@ -96,6 +96,10 @@ class ConfigPermissionProcessor:
"""Evaluate an allow block against the current actor."""
if allow_block is None:
return None
# Values passed using ``-s permissions.* 1`` or ``0`` are parsed as
# integers, but should retain the CLI's boolean 1/0 behavior.
if isinstance(allow_block, int) and allow_block in (0, 1):
return bool(allow_block)
return actor_matches_allow(self.actor, allow_block)
def is_in_restriction_allowlist(

View file

@ -85,7 +85,7 @@ class Facet:
self.database = database
# For foreign key expansion. Can be None for e.g. stored SQL queries:
self.table = table
self.sql = sql or f"select * from [{table}]"
self.sql = sql or f"select * from {escape_sqlite(table)}"
self.params = params or []
self.table_config = table_config
# row_count can be None, in which case we calculate it ourselves:

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,4 @@
import { EditorView, basicSetup } from "codemirror";
import { Compartment } from "@codemirror/state";
import { keymap } from "@codemirror/view";
import { sql, SQLDialect } from "@codemirror/lang-sql";
@ -15,25 +14,12 @@ const SQLite = SQLDialect.define({
operatorChars: "*+-%<>!=&|/~",
identifierQuotes: '`"',
specialVar: "@:?$",
caseInsensitiveIdentifiers: true,
});
// Builds the sql() extension from a {schema, defaultTable, defaultSchema} conf object
function sqlExtension(conf) {
return sql({
dialect: SQLite,
schema: conf.schema,
defaultTable: conf.defaultTable,
defaultSchema: conf.defaultSchema,
});
}
// Utility function from https://codemirror.net/docs/migration/
export function editorFromTextArea(textarea, conf = {}) {
// Wraps the sql() extension so it can be swapped out later via view.updateSchema()
// https://codemirror.net/examples/config/#dynamic-configuration
let sqlCompartment = new Compartment();
// This could also be configured with a set of tables and columns for better autocomplete:
// https://github.com/codemirror/lang-sql#user-content-sqlconfig.tables
let view = new EditorView({
doc: textarea.value,
extensions: [
@ -59,17 +45,16 @@ export function editorFromTextArea(textarea, conf = {}) {
// Meta-Enter from running
basicSetup,
EditorView.lineWrapping,
sqlCompartment.of(sqlExtension(conf)),
sql({
dialect: SQLite,
schema: conf.schema,
tables: conf.tables,
defaultTableName: conf.defaultTableName,
defaultSchemaName: conf.defaultSchemaName,
}),
],
});
// Allows callers (and plugins) to update the schema/defaultTable/defaultSchema
// used for autocomplete after the editor has already been created.
view.updateSchema = (conf2) =>
view.dispatch({
effects: sqlCompartment.reconfigure(sqlExtension(conf2)),
});
// Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.
// Using CSS resize: both and scheduling a measurement when the element changes.
let editorDOM = view.contentDOM.closest(".cm-editor");

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
<script src="{{ static('cm-editor.bundle.js') }}"></script>
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
<style>
.cm-editor {
resize: both;

View file

@ -15,9 +15,6 @@
if (sqlInput) {
var editor = (window.editor = cm.editorFromTextArea(sqlInput, {
schema,
{% if default_table is defined and default_table %}
defaultTable: {{ default_table|tojson }},
{% endif %}
}));
if (sqlFormat) {
sqlFormat.addEventListener("click", (ev) => {

View file

@ -6,8 +6,20 @@
padding: 1.5em;
margin-bottom: 2em;
}
.permission-form form {
max-width: 60rem;
}
.permission-form-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.permission-form-result {
margin-top: 1rem;
max-width: 60rem;
}
.form-section {
margin-bottom: 1em;
margin-bottom: 1.25em;
}
.form-section label {
display: block;
@ -15,22 +27,51 @@
font-weight: bold;
}
.form-section input[type="text"],
.form-section select {
width: 100%;
max-width: 500px;
padding: 0.5em;
.form-section input[type="number"],
.form-section select,
.permission-textarea {
background-color: #fff;
border: 1px solid #aaa;
border-radius: 4px;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 3px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
color: #222;
font-family: inherit;
font-size: 1rem;
line-height: 1.4;
max-width: none;
width: 100%;
}
.form-section input[type="text"] {
height: 3rem;
padding: 0.6rem 0.75rem;
}
.form-section input[type="number"] {
height: 3rem;
max-width: 7rem;
padding: 0.6rem 0.75rem;
}
.form-section select {
height: 3rem;
padding: 0.6rem 0.75rem;
}
.permission-textarea {
font-family: monospace;
min-height: 12rem;
padding: 0.75rem;
resize: vertical;
}
.form-section input[type="text"]:focus,
.form-section select:focus {
outline: 2px solid #0066cc;
.form-section input[type="number"]:focus,
.form-section select:focus,
.permission-textarea:focus {
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
outline: none;
}
.form-section small {
display: block;
margin-top: 0.3em;
margin-top: 0.45em;
color: #666;
}
.form-actions {
@ -142,4 +183,9 @@
text-align: center;
color: #666;
}
@media only screen and (max-width: 576px) {
.permission-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View file

@ -44,10 +44,10 @@
</style>
<nav class="permissions-debug-tabs">
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Explain</a>
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Access map</a>
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rule explorer</a>
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</a>
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
</nav>

View file

@ -3,29 +3,11 @@
{% block title %}Debug allow rules{% endblock %}
{% block extra_head %}
{% include "_permission_ui_styles.html" %}
<style>
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
p.message-warning {
white-space: pre-wrap;
}
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style>
{% endblock %}
@ -38,24 +20,28 @@ p.message-warning {
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
<div class="two-col">
<p><label>Allow block</label></p>
<textarea name="allow">{{ allow_input }}</textarea>
</div>
<div class="two-col">
<p><label>Actor</label></p>
<textarea name="actor">{{ actor_input }}</textarea>
</div>
<div style="margin-top: 1em;">
<input type="submit" value="Apply allow block to actor">
</div>
</form>
<div class="permission-form">
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get">
<div class="permission-form-grid">
<div class="form-section">
<label for="allow-block">Allow block</label>
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea>
</div>
<div class="form-section">
<label for="allow-actor">Actor</label>
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn">Apply allow block to actor</button>
</div>
</form>
{% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
{% if error %}<p class="message-warning permission-form-result">{{ error }}</p>{% endif %}
{% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
{% if result == "True" %}<p class="message-info permission-form-result">Result: allow</p>{% endif %}
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
{% if result == "False" %}<p class="message-error permission-form-result">Result: deny</p>{% endif %}
</div>
{% endblock %}

View file

@ -49,7 +49,7 @@
<div class="form-section">
<label for="page_size">Page size:</label>
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
<small>Number of results per page (max 200)</small>
</div>

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Permission Check{% endblock %}
{% block title %}Explain a permission decision{% endblock %}
{% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
@ -13,29 +13,35 @@
border-radius: 5px;
}
#output.allowed {
background-color: #e8f5e9;
background-color: #f3fbf4;
border: 2px solid #4caf50;
}
#output.denied {
background-color: #ffebee;
background-color: #fff7f7;
border: 2px solid #f44336;
}
#output h2 {
margin-top: 0;
}
#output .result-badge {
#output h3 {
margin-bottom: 0.5em;
}
#output .result-badge,
.effect-badge,
.rule-status {
display: inline-block;
padding: 0.3em 0.8em;
padding: 0.2em 0.5em;
border-radius: 3px;
font-weight: bold;
font-size: 1.1em;
}
#output .allowed-badge {
background-color: #4caf50;
#output .allowed-badge,
.effect-allow {
background-color: #2e7d32;
color: white;
}
#output .denied-badge {
background-color: #f44336;
#output .denied-badge,
.effect-deny {
background-color: #c62828;
color: white;
}
.details-section {
@ -48,70 +54,130 @@
.details-section dd {
margin-left: 1em;
}
.explanation-section {
background: rgba(255, 255, 255, 0.75);
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 1em;
padding: 0 1em 1em;
}
.rules-table {
border-collapse: collapse;
width: 100%;
}
.rules-table th,
.rules-table td {
border-bottom: 1px solid #ddd;
padding: 0.5em;
text-align: left;
vertical-align: top;
}
.rule-status {
background: #e8f5e9;
color: #1b5e20;
}
.rule-ignored {
background: #eee;
color: #555;
font-weight: normal;
}
.requirement-allowed {
color: #1b5e20;
}
.requirement-denied {
color: #b71c1c;
}
@media only screen and (max-width: 576px) {
.rules-table,
.rules-table tbody,
.rules-table tr,
.rules-table td {
display: block;
}
.rules-table thead {
display: none;
}
.rules-table td::before {
content: attr(data-label) ": ";
font-weight: bold;
}
}
</style>
{% endblock %}
{% block content %}
<h1>Permission check</h1>
<h1>Explain a permission decision</h1>
{% set current_tab = "check" %}
{% include "_permissions_debug_tabs.html" %}
<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 %}
<p>Test an actor, action and resource. The result explains which rules matched, which specificity level won, and whether actor restrictions or required actions changed the verdict.</p>
<div class="permission-form">
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
<form id="check-form" method="get" action="{{ urls.path('-/check') }}">
<div class="form-section">
<label for="action">Action (permission name):</label>
<label for="actor">Actor JSON:</label>
<textarea class="permission-textarea" id="actor" name="actor">{{ actor_json }}</textarea>
<small>Use <code>null</code> for an anonymous actor. This actor is simulated; it does not change who you are signed in as.</small>
</div>
<div class="form-section">
<label for="action">Action:</label>
<select id="action" name="action" required>
<option value="">Select an action...</option>
{% for action_name in sorted_actions %}
<option value="{{ action_name }}">{{ action_name }}</option>
{% for action in actions %}
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option>
{% endfor %}
</select>
<small>The permission action to check</small>
<small id="action-help">The operation to evaluate</small>
</div>
<div class="form-section">
<label for="parent">Parent resource (optional):</label>
<div class="form-section" id="parent-section">
<label for="parent">Parent resource:</label>
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
<small>For database-level permissions, specify the database name</small>
<small>The database or other parent resource</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 class="form-section" id="child-section">
<label for="child">Child resource:</label>
<input type="text" id="child" name="child" placeholder="e.g., table or query name">
<small>The table, query or other child resource</small>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
<button type="submit" class="submit-btn" id="submit-btn">Explain decision</button>
</div>
</form>
</div>
<div id="output" style="display: none;">
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
<p id="result-summary"></p>
<dl class="details-section">
<dt>Actor:</dt>
<dd><code id="result-actor"></code></dd>
<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>
<dd><code id="result-action"></code></dd>
<dt>Resource:</dt>
<dd><code id="result-resource"></code></dd>
</dl>
<section class="explanation-section">
<h3>Matching rules</h3>
<div id="matching-rules"></div>
</section>
<section class="explanation-section" id="restrictions-section">
<h3>Actor restrictions</h3>
<div id="restriction-results"></div>
</section>
<section class="explanation-section" id="requirements-section">
<h3>Required actions</h3>
<div id="requirement-results"></div>
</section>
<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>
@ -119,152 +185,134 @@
</div>
<script>
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
const form = document.getElementById('check-form');
const output = document.getElementById('output');
const submitBtn = document.getElementById('submit-btn');
const actionSelect = document.getElementById('action');
function updateResourceFields() {
const action = actions[actionSelect.value];
document.getElementById('parent-section').style.display = action && action.takes_parent ? 'block' : 'none';
document.getElementById('child-section').style.display = action && action.takes_child ? 'block' : 'none';
let help = action && action.description ? action.description : 'The operation to evaluate';
if (action && action.also_requires) {
help += `; also requires ${action.also_requires}`;
}
document.getElementById('action-help').textContent = help;
}
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);
}
}
submitBtn.textContent = 'Explaining...';
const params = new URLSearchParams(new FormData(form));
try {
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
method: 'GET',
headers: {
'Accept': 'application/json',
}
headers: {'Accept': 'application/json'}
});
const data = await response.json();
if (response.ok) {
displayResult(data);
} else {
displayError(data);
}
} catch (error) {
alert('Error: ' + error.message);
displayError({error: error.message});
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Check Permission';
submitBtn.textContent = 'Explain decision';
}
}
// Populate form on initial load
(function() {
const params = populateFormFromURL();
const action = params.get('action');
if (action) {
performCheck();
}
})();
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
output.className = data.allowed ? 'allowed' : 'denied';
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`;
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
document.getElementById('result-summary').textContent = data.explanation.summary;
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor);
document.getElementById('result-action').textContent = data.action;
document.getElementById('result-resource').textContent = data.resource.path;
displayRules(data.explanation);
displayRestrictions(data.explanation.restrictions);
displayRequirements(data.explanation.required_actions);
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
}
// Scroll to output
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
function displayRules(explanation) {
const container = document.getElementById('matching-rules');
if (!explanation.matched_rules.length) {
container.innerHTML = '<p>No rules matched. Datasette denies access when there is no matching rule.</p>';
return;
}
let html = '<table class="rules-table"><thead><tr><th>Effect</th><th>Scope</th><th>Source</th><th>Reason</th><th>Role in decision</th></tr></thead><tbody>';
for (const rule of explanation.matched_rules) {
const status = rule.decisive
? '<span class="rule-status">Decisive</span>'
: `<span class="rule-status rule-ignored">${escapeHtml(rule.ignored_because)}</span>`;
html += '<tr>';
html += `<td data-label="Effect"><span class="effect-badge effect-${rule.effect}">${rule.effect.toUpperCase()}</span></td>`;
html += `<td data-label="Scope">${escapeHtml(rule.scope)}</td>`;
html += `<td data-label="Source"><code>${escapeHtml(rule.source || 'unknown')}</code></td>`;
html += `<td data-label="Reason">${escapeHtml(rule.reason || 'No reason supplied')}</td>`;
html += `<td data-label="Role in decision">${status}</td>`;
html += '</tr>';
}
container.innerHTML = html + '</tbody></table>';
}
function displayRestrictions(restrictions) {
const section = document.getElementById('restrictions-section');
const container = document.getElementById('restriction-results');
section.style.display = restrictions.length ? 'block' : 'none';
container.innerHTML = restrictions.map(restriction => {
const className = restriction.allowed ? 'requirement-allowed' : 'requirement-denied';
const verdict = restriction.allowed ? 'INCLUDED ✓' : 'EXCLUDED ✗';
return `<p class="${className}"><strong>${verdict}</strong> by <code>${escapeHtml(restriction.source || 'unknown')}</code>: ${escapeHtml(restriction.reason)}</p>`;
}).join('');
}
function displayRequirements(requirements) {
const section = document.getElementById('requirements-section');
const container = document.getElementById('requirement-results');
section.style.display = requirements.length ? 'block' : 'none';
container.innerHTML = requirements.map(requirement => {
const className = requirement.allowed ? 'requirement-allowed' : 'requirement-denied';
const verdict = requirement.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
return `<p class="${className}"><strong>${escapeHtml(requirement.action)}: ${verdict}</strong> — ${escapeHtml(requirement.summary)}</p>`;
}).join('');
}
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('result-summary').textContent = data.error || 'Unknown error';
document.getElementById('result-actor').textContent = '—';
document.getElementById('result-action').textContent = '—';
document.getElementById('result-resource').textContent = '—';
document.getElementById('matching-rules').innerHTML = '';
document.getElementById('restrictions-section').style.display = 'none';
document.getElementById('requirements-section').style.display = 'none';
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();
}
form.addEventListener('submit', event => {
event.preventDefault();
performCheck();
});
</script>
actionSelect.addEventListener('change', updateResourceFields);
(function initializeFromUrl() {
const params = populateFormFromURL();
updateResourceFields();
if (params.get('action')) {
performCheck();
}
})();
</script>
{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Debug permissions{% endblock %}
{% block title %}Permission activity{% endblock %}
{% block extra_head %}
{% include "_permission_ui_styles.html" %}
@ -20,60 +20,45 @@
.check-action, .check-when, .check-result {
font-size: 1.3em;
}
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<h1>Permission playground</h1>
<h1>Permission activity</h1>
{% set current_tab = "permissions" %}
{% include "_permissions_debug_tabs.html" %}
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
<h2>Raw simulator</h2>
<p>This form runs a hypothetical permission check and returns its raw explanation JSON. Use the <a href="{{ urls.path('-/check') }}">Explain tool</a> for a visual explanation of the same decision.</p>
<div class="permission-form">
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
<div class="two-col">
<div class="form-section">
<label>Actor</label>
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
<div class="permission-form-grid">
<div>
<div class="form-section">
<label for="activity-actor">Actor</label>
<textarea class="permission-textarea" id="activity-actor" name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
</div>
</div>
</div>
<div class="two-col" style="vertical-align: top">
<div class="form-section">
<label for="permission">Action</label>
<select name="permission" id="permission">
{% for permission in permissions %}
<option value="{{ permission.name }}">{{ permission.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-section">
<label for="resource_1">Parent</label>
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
</div>
<div class="form-section">
<label for="resource_2">Child</label>
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
<div>
<div class="form-section">
<label for="permission">Action</label>
<select name="permission" id="permission">
{% for permission in permissions %}
<option value="{{ permission.name }}">{{ permission.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-section">
<label for="resource_1">Parent</label>
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
</div>
<div class="form-section">
<label for="resource_2">Child</label>
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
</div>
</div>
</div>
<div class="form-actions">
@ -125,7 +110,7 @@ debugPost.addEventListener('submit', function(ev) {
});
</script>
<h1>Recent permissions checks</h1>
<h2>Recent permission checks</h2>
<p>
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},

View file

@ -37,7 +37,7 @@
<div class="form-section">
<label for="page_size">Page size:</label>
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
<small>Number of results per page (max 200)</small>
</div>

View file

@ -126,7 +126,7 @@
{% endif %}
{% if query.sql and allow_execute_sql %}
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql, '_table': table}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
{% endif %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>

View file

@ -636,7 +636,7 @@ def detect_primary_keys(conn, table):
def get_outbound_foreign_keys(conn, table):
infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall()
infos = conn.execute(f"PRAGMA foreign_key_list({escape_sqlite(table)})").fetchall()
fks = []
for info in infos:
if info is not None:

View file

@ -252,88 +252,62 @@ async def _build_single_action_sql(
]
)
# Continue with the cascading logic
query_parts.extend(
[
"child_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
" GROUP BY b.parent, b.child",
"),",
"parent_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
# Continue with the cascading logic.
# Aggregate the RULES by cascade level (small), rather than grouping
# base x rules (which scales with the number of resources).
def _agg(select_key, where, group_by):
parts = [
f" SELECT {select_key}",
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN allow = 0 THEN source_plugin || ': ' || reason END) AS deny_reasons,",
" json_group_array(CASE WHEN allow = 1 THEN source_plugin || ': ' || reason END) AS allow_reasons",
f" FROM all_rules WHERE {where}",
]
if group_by:
parts.append(f" GROUP BY {group_by}")
return parts
query_parts.extend(
["child_agg AS ("]
+ _agg(
"parent, child,",
"parent IS NOT NULL AND child IS NOT NULL",
"parent, child",
)
+ ["),", "parent_agg AS ("]
+ _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
+ ["),", "global_agg AS ("]
+ _agg("", "parent IS NULL AND child IS NULL", None)
+ ["),"]
)
# Add anonymous decision logic if needed
if include_is_private:
query_parts.extend(
[
"anon_child_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
" GROUP BY b.parent, b.child",
"),",
"anon_parent_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_decisions AS (",
" SELECT",
" b.parent, b.child,",
" CASE",
" WHEN acl.any_deny = 1 THEN 0",
" WHEN acl.any_allow = 1 THEN 1",
" WHEN apl.any_deny = 1 THEN 0",
" WHEN apl.any_allow = 1 THEN 1",
" WHEN agl.any_deny = 1 THEN 0",
" WHEN agl.any_allow = 1 THEN 1",
" ELSE 0",
" END AS anon_is_allowed",
" FROM base b",
" JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))",
" JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))",
" JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))",
"),",
def _anon_agg(select_key, where, group_by):
parts = [
f" SELECT {select_key}",
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow",
f" FROM anon_rules WHERE {where}",
]
if group_by:
parts.append(f" GROUP BY {group_by}")
return parts
query_parts.extend(
["anon_child_agg AS ("]
+ _anon_agg(
"parent, child,",
"parent IS NOT NULL AND child IS NOT NULL",
"parent, child",
)
+ ["),", "anon_parent_agg AS ("]
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
+ ["),", "anon_global_agg AS ("]
+ _anon_agg("", "parent IS NULL AND child IS NULL", None)
+ ["),"]
)
# Final decisions
@ -342,31 +316,28 @@ async def _build_single_action_sql(
"decisions AS (",
" SELECT",
" b.parent, b.child,",
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
" -- Cascading permission logic: child -> parent -> global, DENY beats ALLOW at each level",
" -- Priority order:",
" -- 1. Child-level deny (most specific, blocks access)",
" -- 2. Child-level allow (most specific, grants access)",
" -- 3. Parent-level deny (intermediate, blocks access)",
" -- 4. Parent-level allow (intermediate, grants access)",
" -- 5. Global-level deny (least specific, blocks access)",
" -- 6. Global-level allow (least specific, grants access)",
" -- 1. Child-level deny 2. Child-level allow",
" -- 3. Parent-level deny 4. Parent-level allow",
" -- 5. Global-level deny 6. Global-level allow",
" -- 7. Default deny (no rules match)",
" CASE",
" WHEN cl.any_deny = 1 THEN 0",
" WHEN cl.any_allow = 1 THEN 1",
" WHEN pl.any_deny = 1 THEN 0",
" WHEN pl.any_allow = 1 THEN 1",
" WHEN gl.any_deny = 1 THEN 0",
" WHEN gl.any_allow = 1 THEN 1",
" WHEN ca.any_deny = 1 THEN 0",
" WHEN ca.any_allow = 1 THEN 1",
" WHEN pa.any_deny = 1 THEN 0",
" WHEN pa.any_allow = 1 THEN 1",
" WHEN ga.any_deny = 1 THEN 0",
" WHEN ga.any_allow = 1 THEN 1",
" ELSE 0",
" END AS is_allowed,",
" CASE",
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
" WHEN ca.any_deny = 1 THEN ca.deny_reasons",
" WHEN ca.any_allow = 1 THEN ca.allow_reasons",
" WHEN pa.any_deny = 1 THEN pa.deny_reasons",
" WHEN pa.any_allow = 1 THEN pa.allow_reasons",
" WHEN ga.any_deny = 1 THEN ga.deny_reasons",
" WHEN ga.any_allow = 1 THEN ga.allow_reasons",
" ELSE '[]'",
" END AS reason",
]
@ -374,21 +345,34 @@ async def _build_single_action_sql(
if include_is_private:
query_parts.append(
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
" , CASE WHEN ("
"CASE"
" WHEN aca.any_deny = 1 THEN 0"
" WHEN aca.any_allow = 1 THEN 1"
" WHEN apa.any_deny = 1 THEN 0"
" WHEN apa.any_allow = 1 THEN 1"
" WHEN aga.any_deny = 1 THEN 0"
" WHEN aga.any_allow = 1 THEN 1"
" ELSE 0 END"
") = 0 THEN 1 ELSE 0 END AS is_private"
)
query_parts.extend(
[
" FROM base b",
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
" JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child",
" LEFT JOIN parent_agg pa ON pa.parent = b.parent",
" CROSS JOIN global_agg ga",
]
)
if include_is_private:
query_parts.append(
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
query_parts.extend(
[
" LEFT JOIN anon_child_agg aca ON aca.parent = b.parent AND aca.child = b.child",
" LEFT JOIN anon_parent_agg apa ON apa.parent = b.parent",
" CROSS JOIN anon_global_agg aga",
]
)
query_parts.append(")")
@ -400,8 +384,28 @@ async def _build_single_action_sql(
restriction_intersect = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_sqls
)
# Decompose by NULL-pattern so the final filter can use pure-equality
# EXISTS lookups (satisfiable via automatic indexes) instead of a
# correlated OR-scan over the whole list.
query_parts.extend(
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
[
",",
"restriction_list AS (",
f" {restriction_intersect}",
"),",
"restriction_exact AS (",
" SELECT parent, child FROM restriction_list WHERE parent IS NOT NULL AND child IS NOT NULL",
"),",
"restriction_parent_any AS (",
" SELECT DISTINCT parent FROM restriction_list WHERE parent IS NOT NULL AND child IS NULL",
"),",
"restriction_child_any AS (",
" SELECT DISTINCT child FROM restriction_list WHERE parent IS NULL AND child IS NOT NULL",
"),",
"restriction_all AS (",
" SELECT 1 AS matched FROM restriction_list WHERE parent IS NULL AND child IS NULL LIMIT 1",
")",
]
)
# Final SELECT
@ -416,10 +420,11 @@ async def _build_single_action_sql(
# Add restriction filter if there are restrictions
if restriction_sqls:
query_parts.append("""
AND EXISTS (
SELECT 1 FROM restriction_list r
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
AND (r.child = decisions.child OR r.child IS NULL)
AND (
EXISTS (SELECT 1 FROM restriction_all)
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent)
OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child)
OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child)
)""")
# Add parent filter if specified
@ -673,3 +678,239 @@ async def check_permission_for_resource(
child=child,
)
return results[action]
async def explain_permission_for_resource(
*,
datasette: "Datasette",
actor: dict | None,
action: str,
parent: str | None,
child: str | None,
) -> dict:
"""Explain a permission decision for one action and resource.
This is intended for Datasette's permission debugging tools. It uses the
same ``permission_resources_sql`` hook results and the same resolution
rules as :func:`check_permissions_for_actions`, but also returns the
matching rules, actor restriction results and ``also_requires`` chain.
The returned dictionary is part of Datasette's unstable debugging API.
"""
action_obj = datasette.actions.get(action)
if action_obj is None:
raise ValueError(f"Unknown action: {action}")
explanation = await _explain_single_action(
datasette=datasette,
actor=actor,
action=action,
parent=parent,
child=child,
)
required_actions = []
if action_obj.also_requires:
required = await explain_permission_for_resource(
datasette=datasette,
actor=actor,
action=action_obj.also_requires,
parent=parent,
child=child,
)
required_actions.append(required)
explanation["required_actions"] = required_actions
explanation["allowed"] = bool(
explanation["rule_allowed"]
and explanation["restriction_allowed"]
and all(required["allowed"] for required in required_actions)
)
explanation["summary"] = _permission_explanation_summary(explanation)
return explanation
async def _explain_single_action(
*,
datasette: "Datasette",
actor: dict | None,
action: str,
parent: str | None,
child: str | None,
) -> dict:
"""Return matching rules and restrictions for a single action."""
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
permission_sqls = await gather_permission_sql_from_hooks(
datasette=datasette,
actor=actor,
action=action,
)
if permission_sqls is SKIP_PERMISSION_CHECKS:
return {
"action": action,
"rule_allowed": True,
"restriction_allowed": True,
"winning_scope": "global",
"matched_rules": [
{
"scope": "global",
"effect": "allow",
"source": "skip_permission_checks",
"reason": "Permission checks were explicitly skipped",
"decisive": True,
"ignored_because": None,
}
],
"restrictions": [],
}
db = datasette.get_internal_database()
matched_rules = []
restrictions = []
for permission_sql in permission_sqls:
params = dict(permission_sql.params or {})
parent_param = _unused_parameter_name(params, "_explain_parent")
params[parent_param] = parent
child_param = _unused_parameter_name(params, "_explain_child")
params[child_param] = child
if permission_sql.sql:
rows = await db.execute(
f"""
SELECT parent, child, allow, reason
FROM ({permission_sql.sql}) AS permission_rules
WHERE (parent IS NULL OR parent = :{parent_param})
AND (child IS NULL OR child = :{child_param})
""",
params,
)
for row in rows:
specificity = (
2
if row["child"] is not None
else 1 if row["parent"] is not None else 0
)
matched_rules.append(
{
"scope": ("resource", "parent", "global")[2 - specificity],
"effect": "allow" if row["allow"] else "deny",
"source": permission_sql.source,
"reason": row["reason"],
"_specificity": specificity,
}
)
if permission_sql.restriction_sql:
restriction_row = (
await db.execute(
f"""
SELECT EXISTS(
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
WHERE (parent IS NULL OR parent = :{parent_param})
AND (child IS NULL OR child = :{child_param})
) AS resource_is_in_allowlist
""",
params,
)
).first()
restriction_allowed = bool(restriction_row[0])
restrictions.append(
{
"source": permission_sql.source,
"allowed": restriction_allowed,
"reason": params.get("deny")
or (
"Resource is included in this restriction allowlist"
if restriction_allowed
else "Resource is not included in this restriction allowlist"
),
}
)
matched_rules.sort(
key=lambda rule: (
-rule["_specificity"],
0 if rule["effect"] == "deny" else 1,
rule["source"] or "",
rule["reason"] or "",
)
)
if matched_rules:
winning_specificity = matched_rules[0]["_specificity"]
winning_rules = [
rule
for rule in matched_rules
if rule["_specificity"] == winning_specificity
]
rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules)
winning_scope = winning_rules[0]["scope"]
else:
winning_specificity = None
rule_allowed = False
winning_scope = None
for rule in matched_rules:
specificity = rule.pop("_specificity")
if specificity != winning_specificity:
rule["decisive"] = False
rule["ignored_because"] = "A more specific rule matched"
elif not rule_allowed and rule["effect"] == "allow":
rule["decisive"] = False
rule["ignored_because"] = "A deny rule matched at the same scope"
else:
rule["decisive"] = True
rule["ignored_because"] = None
return {
"action": action,
"rule_allowed": rule_allowed,
"restriction_allowed": all(
restriction["allowed"] for restriction in restrictions
),
"winning_scope": winning_scope,
"matched_rules": matched_rules,
"restrictions": restrictions,
}
def _unused_parameter_name(params: dict, preferred: str) -> str:
"""Return a SQL parameter name that is not already in ``params``."""
candidate = preferred
suffix = 2
while candidate in params:
candidate = f"{preferred}_{suffix}"
suffix += 1
return candidate
def _permission_explanation_summary(explanation: dict) -> str:
denied_requirement = next(
(
required
for required in explanation["required_actions"]
if not required["allowed"]
),
None,
)
if denied_requirement:
return (
f"Denied because {explanation['action']} also requires "
f"{denied_requirement['action']}, which was denied."
)
if not explanation["matched_rules"]:
return "Denied because no permission rule matched this actor and resource."
if not explanation["rule_allowed"]:
return (
f"Denied by a {explanation['winning_scope']}-level rule. "
"Deny rules take precedence over allow rules at the same scope."
)
if not explanation["restriction_allowed"]:
return (
"Denied because the resource is not included in the actor's restrictions."
)
return f"Allowed by the matching {explanation['winning_scope']}-level rule."

View file

@ -3,7 +3,7 @@ import textwrap
from sqlite_utils import Database as SQLiteUtilsDatabase
from sqlite_utils import Migrations
from datasette.utils import table_column_details
from datasette.utils import escape_sqlite, table_column_details
INTERNAL_DB_SCHEMA_TABLES = {
"catalog_databases",
@ -180,29 +180,9 @@ async def init_internal_db(db):
await db.execute_write_fn(apply_migrations, transaction=False)
async def populate_schema_tables(internal_db, db):
async def populate_schema_tables(internal_db, db, schema_version):
database_name = db.name
def delete_everything(conn):
conn.execute(
"DELETE FROM catalog_tables WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_views WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_columns WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_foreign_keys WHERE database_name = ?",
[database_name],
)
conn.execute(
"DELETE FROM catalog_indexes WHERE database_name = ?", [database_name]
)
await internal_db.execute_write_fn(delete_everything)
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
@ -233,7 +213,7 @@ async def populate_schema_tables(internal_db, db):
for column in columns
)
foreign_keys = conn.execute(
f"PRAGMA foreign_key_list([{table_name}])"
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})"
).fetchall()
foreign_keys_to_insert.extend(
{
@ -242,7 +222,9 @@ async def populate_schema_tables(internal_db, db):
}
for foreign_key in foreign_keys
)
indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
indexes = conn.execute(
f"PRAGMA index_list({escape_sqlite(table_name)})"
).fetchall()
indexes_to_insert.extend(
{
**{"database_name": database_name, "table_name": table_name},
@ -266,47 +248,76 @@ async def populate_schema_tables(internal_db, db):
indexes_to_insert,
) = await db.execute_fn(collect_info)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
values (?, ?, ?, ?)
""",
tables_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
values (?, ?, ?, ?)
""",
views_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_columns (
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
) VALUES (
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
def replace_catalog(conn):
# Delete child rows before their catalog_tables parents so this also
# works if a prepare_connection plugin enables foreign key enforcement.
for table in (
"catalog_columns",
"catalog_foreign_keys",
"catalog_indexes",
"catalog_views",
"catalog_tables",
):
conn.execute(
"DELETE FROM {} WHERE database_name = ?".format(table),
[database_name],
)
conn.execute(
"""
INSERT OR REPLACE INTO catalog_databases (
database_name, path, is_memory, schema_version
) VALUES (?, ?, ?, ?)
""",
[
database_name,
str(db.path) if db.path is not None else None,
db.is_memory,
schema_version,
],
)
""",
columns_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_foreign_keys (
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
) VALUES (
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
conn.executemany(
"""
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
values (?, ?, ?, ?)
""",
tables_to_insert,
)
""",
foreign_keys_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_indexes (
database_name, table_name, seq, name, "unique", origin, partial
) VALUES (
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
conn.executemany(
"""
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
values (?, ?, ?, ?)
""",
views_to_insert,
)
""",
indexes_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_columns (
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
) VALUES (
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
)
""",
columns_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_foreign_keys (
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
) VALUES (
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
)
""",
foreign_keys_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_indexes (
database_name, table_name, seq, name, "unique", origin, partial
) VALUES (
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
)
""",
indexes_to_insert,
)
await internal_db.execute_write_fn(replace_catalog)

View file

@ -1,2 +1,2 @@
__version__ = "1.0a36"
__version__ = "1.0a37"
__version_info__ = tuple(__version__.split("."))

View file

@ -36,10 +36,7 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
from datasette.plugins import pm
from .base import DatasetteError, View, stream_csv
from .query_helpers import (
_ensure_stored_query_execution_permissions,
_editor_schema,
)
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from .table_extras import (
QueryExtraContext,
resolve_query_extras,
@ -204,7 +201,7 @@ class DatabaseView(View):
"queries_count": queries_count,
"allow_execute_sql": allow_execute_sql,
"table_columns": (
await _editor_schema(datasette, database) if allow_execute_sql else {}
await _table_columns(datasette, database) if allow_execute_sql else {}
),
"metadata": await datasette.get_database_metadata(database),
}
@ -245,7 +242,7 @@ class DatabaseView(View):
queries_count=queries_count,
allow_execute_sql=allow_execute_sql,
table_columns=(
await _editor_schema(datasette, database)
await _table_columns(datasette, database)
if allow_execute_sql
else {}
),
@ -457,11 +454,6 @@ class QueryContext(Context):
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
}
)
default_table: str = field(
metadata={
"help": "Name of the focal table for this query, if any - set when the query page was reached from a table-scoped context (such as the table page's 'View and edit SQL' link) so the SQL editor can complete that table's columns unprefixed. ``None`` otherwise, including for stored/canned queries."
}
)
alternate_url_json: str = field(
metadata={"help": "URL for alternate JSON version of this page"}
)
@ -651,8 +643,15 @@ class QueryView(View):
ok = None
redirect_url = None
try:
execute_write_kwargs = {"request": request}
if stored_query.is_trusted:
analysis = await db.analyze_sql(stored_query.sql, params_for_query)
if any(
operation.operation == "vacuum" for operation in analysis.operations
):
execute_write_kwargs["transaction"] = False
cursor = await db.execute_write(
stored_query.sql, params_for_query, request=request
stored_query.sql, params_for_query, **execute_write_kwargs
)
# success message can come from on_success_message or on_success_message_sql
message = None
@ -721,15 +720,6 @@ class QueryView(View):
# Create lookup dict for quick access
allowed_dict = {r.child: r for r in allowed_tables_page.resources}
# If the request carries a ?_table= pointing at a real (visible) table
# or view in this database, treat this as a table-scoped query - e.g.
# arriving here via the "View and edit SQL" link on a table page - so
# the SQL editor can offer that table's columns unprefixed. Anything
# else (including stored/canned queries, which may reference more
# than one table) leaves this as None.
requested_table = request.args.get("_table")
default_table = requested_table if requested_table in allowed_dict else None
# Are we a stored query?
stored_query = None
stored_query_write = False
@ -1111,11 +1101,10 @@ class QueryView(View):
datasette, database, request, rows, columns
),
table_columns=(
await _editor_schema(datasette, database)
await _table_columns(datasette, database)
if allow_execute_sql
else {}
),
default_table=default_table,
columns=columns,
renderers=renderers,
url_csv=datasette.urls.path(

View file

@ -21,7 +21,6 @@ from .query_helpers import (
_inserted_row_url,
_json_or_form_payload,
_prepare_execute_write,
_editor_schema,
_table_columns,
_wants_json,
)
@ -267,7 +266,6 @@ class ExecuteWriteView(BaseView):
write_template_tables = await _write_template_tables(
self.ds, db, table_columns, hidden_table_names, request.actor
)
editor_schema = await _editor_schema(self.ds, db.name)
write_template_operations = _write_template_operations(write_template_tables)
write_create_table_template_sql = await _create_table_template_sql(
self.ds, db, request.actor
@ -330,7 +328,7 @@ class ExecuteWriteView(BaseView):
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
"execute_disabled": bool(execute_disabled_reason),
"execute_disabled_reason": execute_disabled_reason,
"table_columns": editor_schema,
"table_columns": table_columns,
"write_template_tables": write_template_tables,
"write_template_operations": write_template_operations,
"write_create_table_template_sql": write_create_table_template_sql,

View file

@ -634,92 +634,3 @@ async def _table_columns(datasette, database_name):
for view_name in await db.view_names():
table_columns[view_name] = []
return table_columns
def _column_completion(name, type_):
# A @codemirror/lang-sql Completion object for a single column. boost keeps
# columns ranked above bare SQL keywords in the autocomplete popup.
completion = {
"label": name,
"type": "property",
"boost": 10,
}
if type_:
completion["detail"] = type_
return completion
async def _schema_tables(datasette, database_name, *, include_hidden=True):
"""
Neutral introspection of a database's tables and views for SQL editors.
Returns an ordered list of dicts, one per table or view::
{"name": str, "view": bool,
"columns": [{"name": str, "type": str}, ...]}
``type`` is the SQLite declared column type (empty string when the column
has no declared type). Regular-table columns come from the internal
``catalog_columns`` catalog; views are absent from that catalog so their
columns are read directly via PRAGMA table_xinfo. Hidden tables (FTS shadow
tables and the like) are excluded unless ``include_hidden`` is True. This is
the shared, serialization-agnostic source for both ``_editor_schema`` (which
maps it to lang-sql Completion objects) and the ``/-/editor-schema.json``
endpoint (which emits it directly).
"""
internal_db = datasette.get_internal_database()
result = await internal_db.execute(
"select table_name, name, type from catalog_columns where database_name = ?",
[database_name],
)
table_columns = {}
for row in result.rows:
table_columns.setdefault(row["table_name"], []).append(
{"name": row["name"], "type": row["type"]}
)
db = datasette.get_database(database_name)
hidden = set() if include_hidden else set(await db.hidden_table_names())
tables = []
for table_name, columns in table_columns.items():
if table_name in hidden:
continue
tables.append({"name": table_name, "view": False, "columns": columns})
# Views are not represented in catalog_columns, so pull their real columns
# directly (PRAGMA table_xinfo works against views too).
for view_name in await db.view_names():
columns = [
{"name": column.name, "type": column.type}
for column in await db.table_column_details(view_name)
]
tables.append({"name": view_name, "view": True, "columns": columns})
return tables
async def _editor_schema(datasette, database_name):
"""
Build a lang-sql SQLNamespace for the CodeMirror SQL editor autocomplete.
Returns a dict keyed by table or view name. Table values are lists of
Completion objects (one per column, carrying the column's SQLite type as
``detail``). Views are wrapped in a ``{"self": Completion, "children": [...]}``
container so the popup can label them as views while still completing their
real columns. See @codemirror/lang-sql >= 6.6 SQLNamespace / Completion.
"""
schema = {}
for table in await _schema_tables(datasette, database_name, include_hidden=True):
completions = [
_column_completion(column["name"], column["type"])
for column in table["columns"]
]
if table["view"]:
schema[table["name"]] = {
"self": {
"label": table["name"],
"type": "class",
"detail": "view",
},
"children": completions,
}
else:
schema[table["name"]] = completions
return schema

View file

@ -600,7 +600,7 @@ class PermissionRulesView(BaseView):
async def _check_permission_for_actor(ds, action, parent, child, actor):
"""Shared logic for checking permissions. Returns a dict with check results."""
"""Shared logic for checking and explaining a permission decision."""
if action not in ds.actions:
return error_body(f"Unknown action: {action}", 404), 404
@ -629,15 +629,28 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
from datasette.utils.actions_sql import explain_permission_for_resource
explanation = await explain_permission_for_resource(
datasette=ds,
actor=actor,
action=action,
parent=parent,
child=child,
)
response = {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"action": action,
"allowed": bool(allowed),
"actor": actor,
"resource": {
"parent": parent,
"child": child,
"path": _resource_path(parent, child),
},
"explanation": explanation,
}
if actor and "id" in actor:
@ -655,11 +668,25 @@ class PermissionCheckView(BaseView):
as_format = request.url_vars.get("format")
if not as_format:
actions = [
{
"name": action.name,
"description": action.description,
"takes_parent": action.takes_parent,
"takes_child": action.takes_child,
"also_requires": action.also_requires,
}
for action in sorted(
self.ds.actions.values(), key=lambda action: action.name
)
]
return await self.render(
["debug_check.html"],
request,
{
"sorted_actions": sorted(self.ds.actions.keys()),
"actions": actions,
"actor_json": request.args.get("actor")
or json.dumps(request.actor, indent=2),
"has_debug_permission": True,
},
)
@ -671,9 +698,18 @@ class PermissionCheckView(BaseView):
parent = request.args.get("parent")
child = request.args.get("child")
actor = request.actor
actor_json = request.args.get("actor")
if actor_json is not None:
try:
actor = json.loads(actor_json)
except json.JSONDecodeError as ex:
return Response.error(f"Invalid actor JSON: {ex}", 400)
if actor is not None and not isinstance(actor, dict):
return Response.error("actor must be a JSON object or null", 400)
response, status = await _check_permission_for_actor(
self.ds, action, parent, child, request.actor
self.ds, action, parent, child, actor
)
return Response.json(response, status=status)
@ -1345,59 +1381,6 @@ class DatabaseSchemaView(SchemaBaseView):
return await self.format_html_response(request, schemas)
class DatabaseEditorSchemaView(BaseView):
"""
JSON introspection of a database's tables, views and columns shaped for SQL
editor autocomplete consumers (the CodeMirror ``<datasette-sql-editor>``
component and external clients such as datasette-paper).
Distinct from :class:`DatabaseSchemaView` (``/<db>/-/schema.json``), which
returns the raw DDL as a SQL string gated on ``view-database`` alone. This
endpoint returns a neutral structured payload and is gated on both
``view-database`` and ``execute-sql`` the same permissions as the inline
editor schema handed to the SQL query page.
"""
name = "database_editor_schema"
has_json_alternate = False
async def get(self, request):
from .query_helpers import _schema_tables
database_name = request.url_vars["database"]
# view-database is checked first so actors without it cannot
# distinguish an existing database from a missing one, and a denied
# request only ever leaks the permission action name, never table names.
await self.ds.ensure_permission(
action="view-database",
resource=DatabaseResource(database=database_name),
actor=request.actor,
)
if database_name not in self.ds.databases:
headers = {}
if self.ds.cors:
add_cors_headers(headers)
return Response.json(
error_body("Database not found", 404), status=404, headers=headers
)
await self.ds.ensure_permission(
action="execute-sql",
resource=DatabaseResource(database=database_name),
actor=request.actor,
)
await self.ds.refresh_schemas()
tables = await _schema_tables(self.ds, database_name, include_hidden=False)
headers = {}
if self.ds.cors:
add_cors_headers(headers)
return Response.json(
{"database": database_name, "tables": tables}, headers=headers
)
class TableSchemaView(SchemaBaseView):
"""
Displays schema for a specific table.

View file

@ -45,7 +45,7 @@ Using the "root" actor
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user has **all permissions** - they can perform any action regardless of other permission rules.
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user starts with **all permissions**: Datasette contributes a global allow rule for every action. More specific deny rules can still override that global rule.
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
@ -84,12 +84,12 @@ Click on that link and then visit ``http://127.0.0.1:8001/-/actor`` to confirm t
Permissions
===========
Datasette's permissions system is built around SQL queries. Datasette and its plugins construct SQL queries to resolve the list of resources that an actor cas access.
The key question the permissions system answers is this:
Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?
Every permission decision can be understood in terms of those three values. Datasette implements the decisions using SQL, but you do not need to understand the generated SQL to configure or debug permissions.
**Actors** are :ref:`described above <authentication_actor>`.
An **action** is a string describing the action the actor would like to perform. A full list is :ref:`provided below <actions>` - examples include ``view-table`` and ``execute-sql``.
@ -138,7 +138,51 @@ This configuration will deny access to everyone except the user with ``id`` of `
How permissions are resolved
----------------------------
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
Permission rules describe an effect (``allow`` or ``deny``) at one of three levels:
``resource``
A specific child resource, such as the ``analytics/sales`` table.
``parent``
A parent resource, such as the ``analytics`` database. A parent rule also applies to its child resources.
``global``
Every resource for that action.
Datasette resolves matching rules from most specific to least specific:
#. Resource rules take precedence over parent and global rules.
#. Parent rules take precedence over global rules.
#. If both allow and deny rules match at the same level, deny takes precedence.
#. If no rule matches, access is denied.
This means a resource-level allow can provide an exception to a parent-level deny. It also means that two plugins which disagree at the same level resolve to deny.
.. list-table:: Permission rule examples
:header-rows: 1
* - Matching rules
- Result
- Explanation
* - Global allow
- Allow
- The global rule is the most specific matching rule.
* - Global allow, parent deny
- Deny
- The parent rule is more specific.
* - Parent deny, resource allow
- Allow
- The resource rule is more specific.
* - Resource allow and resource deny
- Deny
- Deny takes precedence at the same level.
* - No matching rules
- Deny
- Permissions default to deny when no rule applies.
The built-in public defaults are global allow rules for actions such as ``view-instance``, ``view-database`` and ``view-table``. They follow the same precedence rules as configuration and plugin rules. The ``--default-deny`` option prevents Datasette from contributing those default allow rules.
Datasette performs checks using :ref:`datasette_allowed`, which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified.
@ -149,12 +193,12 @@ resources were allowed or denied. The combined sources are:
* ``allow`` blocks configured in :ref:`datasette.yaml <authentication_permissions_config>`.
* :ref:`Actor restrictions <authentication_cli_create_token_restrict>` encoded into the actor dictionary or API token.
* The "root" user shortcut when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active, replying ``True`` to all permission chucks unless configuration rules deny them at a more specific level.
* The "root" user rule when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active. This is a global allow rule, so a more specific configuration deny can override it.
* Any additional SQL provided by plugins implementing :ref:`plugin_hook_permission_resources_sql`.
Datasette evaluates the SQL to determine if the requested ``resource`` is
included. Explicit deny rules returned by configuration or plugins will block
access even if other rules allowed it.
Actor restrictions are applied after the allow/deny rules. They act as an additional allowlist: a restriction can remove access but cannot grant access that the actor did not already have. See :ref:`authentication_cli_create_token_restrict`.
Some actions have dependencies on other actions. These are evaluated as an ``AND`` condition. For example, ``execute-sql`` also requires ``view-database``: both decisions must be allowed for the final result to be allowed.
.. _authentication_permissions_allow:
@ -1145,11 +1189,21 @@ The debug tool at ``/-/permissions`` is available to any actor with the ``permis
datasette -s permissions.permissions-debug true data.db
The page shows the permission checks that have been carried out by the Datasette instance.
The permission debug tools answer four different questions:
It also provides an interface for running hypothetical permission checks against a hypothetical actor. This is a useful way of confirming that your configured permissions work in the way you expect.
Why was this decision allowed or denied?
Use :ref:`PermissionCheckView`. It shows every matching rule, identifies the winning specificity level, applies actor restrictions and evaluates any required actions.
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.
Which resources can the current actor access?
Use :ref:`AllowedResourcesView` to view an access map for a selected action.
Which raw rules did Datasette and its plugins contribute?
Use :ref:`PermissionRulesView` to inspect the rules before they are resolved into decisions.
Which checks has this Datasette instance performed recently?
Use ``/-/permissions`` to view recent permission activity.
These tools are designed to help administrators and plugin authors understand and confirm the effective permissions configuration.
These debug endpoints are exempt from the :ref:`JSON API stability promise <json_api_stability>` - their JSON shapes may change in future releases.
@ -1184,11 +1238,20 @@ This endpoint requires the ``permissions-debug`` permission.
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.
The ``/-/check`` endpoint evaluates and explains a single actor, action and resource decision. The explanation includes:
* Every matching allow and deny rule, with its source and reason.
* The winning resource, parent or global scope.
* Rules ignored because a more specific rule matched, or because a deny won at the same scope.
* Actor restriction allowlists that included or excluded the resource.
* Additional actions required by the requested action.
* An explicit default-deny explanation when no rule matched.
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.
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource. The interactive form also accepts actor JSON, allowing a hypothetical actor to be tested without signing in as that actor. The JSON endpoint accepts the same value using the ``actor`` query string parameter. Use ``actor=null`` to represent an anonymous actor.
This endpoint requires the ``permissions-debug`` permission. The hypothetical actor is used only for the decision being explained; access to the debug tool is checked against the actor who is actually signed in.
.. _authentication_ds_actor:

View file

@ -4,6 +4,20 @@
Changelog
=========
.. _v1_0_a37:
1.0a37 (2026-07-14)
-------------------
Performance improvement for SQL-backed permission checks, plus an improved permission debugging interface.
- SQL used to resolve permission checks now aggregates permission rules before joining them to resources, improving performance on instances with large schemas. (:issue:`2832`)
- The :ref:`PermissionCheckView` permission debugger now explains why a decision was allowed or denied, including the matching rules. The interactive form can also test a hypothetical actor supplied as JSON, and the :ref:`permissions documentation <authentication_permissions_explained>` now describes resolution rules in more detail. (:issue:`2841`)
- :ref:`db.execute_write(sql, ..., transaction=True) <database_execute_write>` has a new ``transaction=`` parameter, which can be set to ``False`` for statements such as ``VACUUM`` that cannot run inside a transaction. Write tasks now start their transactions using ``BEGIN IMMEDIATE``, which also ensures that writes are rolled back if the task fails. (:issue:`2831`)
- Refreshing a database's schema in Datasette's internal catalog is now performed as a single atomic operation. (:issue:`2831`)
- Fixed schema introspection, table pages, facets and table counts for tables with names containing a ``]`` character. Thanks, `TowyTowy <https://github.com/TowyTowy>`__. (:issue:`2431`, :pr:`2846`)
- ``/-/plugins.json`` once again returns a top-level JSON array of plugin objects, reverting the object envelope introduced in 1.0a36. This should fix a large number of trivial test failures in existing plugins. (:issue:`2842`, :pr:`2843`)
.. _v1_0_a36:
1.0a36 (2026-07-07)

View file

@ -434,10 +434,13 @@ Datasette bundles `CodeMirror <https://codemirror.net/>`__ for the SQL editing i
npm i codemirror @codemirror/lang-sql
* Build the bundle with::
* Build the bundle using the version number from package.json with::
npm install && npm run build:codemirror
node_modules/.bin/rollup datasette/static/cm-editor-6.0.1.js \
-f iife \
-n cm \
-o datasette/static/cm-editor-6.0.1.bundle.js \
-p @rollup/plugin-node-resolve \
-p @rollup/plugin-terser
This runs ``rollup -c`` against the ``rollup.config.mjs`` file at the root of the repository, which reads ``datasette/static/cm-editor.js`` and writes the bundled, minified output to ``datasette/static/cm-editor.bundle.js``. The bundle filename does not include the CodeMirror version number, so no template needs to be updated.
* Commit the rebuilt ``datasette/static/cm-editor.bundle.js`` - the bundle is checked into the repository.
* Update the version reference in the ``codemirror.html`` template.

View file

@ -2023,8 +2023,8 @@ Example usage:
.. _database_execute_write:
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10)
--------------------------------------------------------------------------------------------------------
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10, transaction=True)
--------------------------------------------------------------------------------------------------------------------------
SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received.
@ -2059,7 +2059,9 @@ If you need to retrieve every row returned by a statement, pass ``return_all=Tru
If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task.
Each call to ``execute_write()`` will be executed inside a transaction.
Each call to ``execute_write()`` will be executed inside a transaction. Pass
``transaction=False`` for statements such as ``VACUUM`` that cannot run inside
a transaction.
.. _database_execute_write_script:

View file

@ -80,18 +80,15 @@ Shows a list of currently installed plugins and their versions. `Plugins example
.. code-block:: json
{
"ok": true,
"plugins": [
{
"name": "datasette_cluster_map",
"static": true,
"templates": false,
"version": "0.10",
"hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"]
}
]
}
[
{
"name": "datasette_cluster_map",
"static": true,
"templates": false,
"version": "0.10",
"hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"]
}
]
Add ``?all=1`` to include details of the default plugins baked into Datasette.

View file

@ -152,62 +152,6 @@ Values for named SQL parameters can be provided as additional query string param
The response uses the same default representation described above.
.. _json_api_editor_schema:
.. _DatabaseEditorSchemaView:
Schema for SQL editors
----------------------
The ``/-/editor-schema.json`` endpoint returns a machine-readable description of
a database's tables, views and columns, shaped for SQL editor autocomplete. It
powers Datasette's own CodeMirror SQL editor and is available for external
consumers such as embeddable editor components.
::
GET /<database>/-/editor-schema.json
Access requires both the :ref:`actions_view_database` and
:ref:`actions_execute_sql` permissions for the database - the same gate as the
inline editor schema on the SQL query page. A request that fails either check
receives a ``403`` JSON error that does not reveal any table or column names.
The response is a neutral structure - a ``database`` name and a list of
``tables``, each with a ``view`` flag (``true`` for SQL views) and a list of
``columns`` carrying the SQLite declared ``type`` (an empty string when the
column has no declared type):
.. code-block:: json
{
"database": "fixtures",
"tables": [
{
"name": "facetable",
"view": false,
"columns": [
{"name": "pk", "type": "INTEGER"},
{"name": "state", "type": "TEXT"}
]
},
{
"name": "paginated_view",
"view": true,
"columns": [
{"name": "content", "type": "TEXT"}
]
}
]
}
Hidden tables - such as the shadow tables that back SQLite full-text search -
are excluded from the response.
This endpoint is distinct from the :ref:`database schema endpoint <DatabaseSchemaView>`
at ``/<database>/-/schema.json``, which returns the raw ``CREATE`` statements as
a SQL string.
.. _json_api_shapes:
Different shapes

View file

@ -168,9 +168,6 @@ The page for arbitrary SQL queries (/database/-/query?sql=...) and stored querie
``db_is_immutable`` - ``bool``
Boolean indicating if this database is immutable
``default_table`` - ``str``
Name of the focal table for this query, if any - set when the query page was reached from a table-scoped context (such as the table page's 'View and edit SQL' link) so the SQL editor can complete that table's columns unprefixed. ``None`` otherwise, including for stored/canned queries.
``display_rows`` - ``list``
List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``.

749
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,16 +5,14 @@
"prettier": "^3.0.0"
},
"scripts": {
"build:codemirror": "rollup -c",
"fix": "npm run prettier -- --write",
"prettier": "prettier 'datasette/static/*[!.min|bundle].js'"
},
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/state": "^6.7.1",
"@codemirror/lang-sql": "^6.3.3",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-terser": "^0.1.0",
"codemirror": "^6.0.2",
"codemirror": "^6.0.1",
"rollup": "^3.30.0"
}
}

View file

@ -1,12 +0,0 @@
import { nodeResolve } from "@rollup/plugin-node-resolve";
import terser from "@rollup/plugin-terser";
export default {
input: "datasette/static/cm-editor.js",
output: {
file: "datasette/static/cm-editor.bundle.js",
format: "iife",
name: "cm",
},
plugins: [nodeResolve(), terser()],
};

View file

@ -1,6 +1,6 @@
from datasette.app import Datasette
from datasette.plugins import DEFAULT_PLUGINS
from datasette.utils import UNSTABLE_API_MESSAGE
from datasette.utils import UNSTABLE_API_MESSAGE, escape_sqlite, tilde_encode
from datasette.utils.sqlite import sqlite_version
from datasette.version import __version__
from .fixtures import make_app_client, EXPECTED_PLUGINS
@ -614,13 +614,13 @@ async def test_plugins_json(ds_client):
response = await ds_client.get("/-/plugins.json")
# Filter out TrackEventPlugin
actual_plugins = sorted(
[p for p in response.json()["plugins"] if p["name"] != "TrackEventPlugin"],
[p for p in response.json() if p["name"] != "TrackEventPlugin"],
key=lambda p: p["name"],
)
assert EXPECTED_PLUGINS == actual_plugins
# Try with ?all=1
response = await ds_client.get("/-/plugins.json?all=1")
names = {p["name"] for p in response.json()["plugins"]}
names = {p["name"] for p in response.json()}
assert names.issuperset(p["name"] for p in EXPECTED_PLUGINS)
assert names.issuperset(DEFAULT_PLUGINS)
@ -930,6 +930,37 @@ async def test_tilde_encoded_database_names(db_name):
assert response2.status_code == 200
@pytest.mark.asyncio
@pytest.mark.parametrize("table_name", ("[foo]", "foo]", "[foo]/bar"))
async def test_table_with_reserved_characters_in_name(table_name):
# Table names containing characters such as "]" that cannot be escaped
# using SQLite [bracket] quoting used to break schema introspection and
# the table page - https://github.com/simonw/datasette/issues/2431
ds = Datasette()
db = ds.add_memory_database("test_reserved_table_names")
await db.execute_write(
"create table {} (id integer primary key, name text)".format(
escape_sqlite(table_name)
)
)
await db.execute_write(
"insert into {} (id, name) values (1, 'one')".format(escape_sqlite(table_name))
)
# Schema introspection (populate_schema_tables) must not crash:
db_response = await ds.client.get("/test_reserved_table_names.json")
assert db_response.status_code == 200
tables = {t["name"]: t for t in db_response.json()["tables"]}
assert tables[table_name]["count"] == 1
# And the table page itself must load and return the row:
table_response = await ds.client.get(
"/test_reserved_table_names/{}.json?_shape=array".format(
tilde_encode(table_name)
)
)
assert table_response.status_code == 200
assert table_response.json() == [{"id": 1, "name": "one"}]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"config,expected",

View file

@ -109,7 +109,7 @@ def test_settings(config_dir_client):
def test_plugins(config_dir_client):
response = config_dir_client.get("/-/plugins.json")
assert 200 == response.status
plugins = response.json["plugins"]
plugins = response.json
assert "hooray.py" in {p["name"] for p in plugins}
assert "non_py_file.txt" not in {p["name"] for p in plugins}
assert "mypy_cache" not in {p["name"] for p in plugins}

View file

@ -284,53 +284,6 @@ async def test_query_page_with_no_sql(ds_client):
assert 'class="rows-and-columns"' not in response.text
@pytest.mark.asyncio
async def test_table_page_view_and_edit_sql_link_carries_table(ds_client):
# The table page's "View and edit SQL" link should point at the query
# page with a &_table= param identifying the focal table, so the SQL
# editor can offer that table's columns unprefixed.
response = await ds_client.get("/fixtures/facetable")
assert response.status_code == 200
soup = Soup(response.content, "html.parser")
link = soup.find("span", string="View and edit SQL").find_parent("a")
assert link is not None
assert "_table=facetable" in link["href"]
@pytest.mark.asyncio
async def test_query_page_default_table_from_table_scoped_link(ds_client):
# Following the table page's edit-SQL link should result in a query page
# whose SQL editor is initialized with defaultTable set to that table.
table_response = await ds_client.get("/fixtures/facetable")
soup = Soup(table_response.content, "html.parser")
href = soup.find("span", string="View and edit SQL").find_parent("a")["href"]
response = await ds_client.get(href, follow_redirects=True)
assert response.status_code == 200
assert 'defaultTable: "facetable"' in response.text
@pytest.mark.asyncio
async def test_query_page_no_default_table_without_table_scope(ds_client):
# The plain database query page (no focal table) should not set
# defaultTable at all.
response = await ds_client.get("/fixtures/-/query?sql=select+1")
assert response.status_code == 200
assert "defaultTable" not in response.text
@pytest.mark.asyncio
async def test_query_page_ignores_invalid_table_param(ds_client):
# A ?_table= value that isn't a real table/view in this database should
# not be reflected back into the page - and should not break execution
# of the query itself (leading-underscore params are not treated as SQL
# bind parameters unless they appear as :name in the SQL).
response = await ds_client.get(
"/fixtures/-/query?sql=select+1&_table=not_a_real_table"
)
assert response.status_code == 200
assert "defaultTable" not in response.text
@pytest.mark.asyncio
async def test_query_csv_with_no_sql_is_400(ds_client):
# https://github.com/simonw/datasette/issues/2743

View file

@ -11,6 +11,7 @@ from datasette.database import _deliver_write_result
from datasette.utils.sqlite import sqlite3, supports_returning
from datasette.utils import Column
import pytest
import sqlite_utils
import time
import uuid
@ -718,6 +719,41 @@ async def test_execute_write_fn_exception(db):
await db.execute_write_fn(write_fn)
@pytest.mark.asyncio
@pytest.mark.parametrize("num_sql_threads", (0, 1))
async def test_execute_write_fn_sqlite_utils_transaction(tmp_path, num_sql_threads):
# A write inside a failing Datasette task must never become visible or
# survive the rollback. Exercise both the synchronous and writer-thread
# paths against a file-backed database so a second connection can observe
# committed state independently.
db_path = tmp_path / "test.db"
sqlite3.connect(db_path).close()
ds = Datasette([str(db_path)], settings={"num_sql_threads": num_sql_threads})
db = ds.get_database("test")
await db.execute_write("create table items (id integer primary key)")
# This reader is used inside the write callback, which may run on another
# thread, but it is never accessed concurrently.
reader = sqlite3.connect(db_path, check_same_thread=False)
def insert_then_fail(conn):
# Datasette must open the outer transaction before sqlite-utils writes.
assert conn.in_transaction
sqlite_utils.Database(conn)["items"].insert({"id": 1})
# If sqlite-utils committed its own transaction, this would return 1.
assert reader.execute("select count(*) from items").fetchone()[0] == 0
# Simulate a later step failing after the sqlite-utils write succeeded.
raise ValueError("deliberate")
try:
with pytest.raises(ValueError, match="deliberate"):
await db.execute_write_fn(insert_then_fail)
# The outer transaction must roll back the sqlite-utils write as well.
assert reader.execute("select count(*) from items").fetchone()[0] == 0
finally:
reader.close()
db.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("param_name", ["conn", "connection", "db", "c"])
async def test_execute_write_fn_accepts_any_single_param_name(db, param_name):

View file

@ -295,24 +295,13 @@ def test_execute_sql(config):
# Extract the schema= portion of the JavaScript
schema_json = schema_re.search(response_text).group(1)
schema = json.loads(schema_json)
assert {c["label"] for c in schema["attraction_characteristic"]} == {
"name",
"pk",
}
# Views are self/children containers carrying their real columns
assert schema["paginated_view"]["self"]["detail"] == "view"
assert {c["label"] for c in schema["paginated_view"]["children"]} == {
"content",
"content_extra",
}
assert set(schema["attraction_characteristic"]) == {"name", "pk"}
assert schema["paginated_view"] == []
assert form_fragment in response_text
query_response = client.get("/fixtures/-/query?sql=select+1", cookies=cookies)
assert query_response.status == 200
schema2 = json.loads(schema_re.search(query_response.text).group(1))
assert {c["label"] for c in schema2["attraction_characteristic"]} == {
"name",
"pk",
}
assert set(schema2["attraction_characteristic"]) == {"name", "pk"}
assert (
client.get("/fixtures/facet_cities?_where=id=3", cookies=cookies).status
== 200
@ -468,6 +457,20 @@ async def test_permissions_debug(ds_client, filter_):
assert checks == expected_checks
@pytest.mark.asyncio
@pytest.mark.parametrize(
"permissions_debug,expected_status",
(
(1, 200),
(0, 403),
),
)
async def test_permissions_debug_numeric_boolean(permissions_debug, expected_status):
ds = Datasette(config={"permissions": {"permissions-debug": permissions_debug}})
response = await ds.client.get("/-/permissions")
assert response.status_code == expected_status
@pytest.mark.asyncio
@pytest.mark.parametrize(
"actor,allow,expected_fragment",
@ -759,7 +762,12 @@ async def test_actor_restricted_permissions(
}
if actor.get("id"):
expected["actor_id"] = actor["id"]
assert response.json() == expected
data = response.json()
for key, value in expected.items():
assert data[key] == value
assert data["actor"] == actor
assert data["explanation"]["allowed"] is expected_result
assert data["explanation"]["summary"]
PermConfigTestCase = collections.namedtuple(
@ -1745,6 +1753,8 @@ async def test_permission_check_view_requires_debug_permission():
data = response.json()
assert data["action"] == "view-instance"
assert data["allowed"] is True
assert data["explanation"]["allowed"] is True
assert data["explanation"]["summary"]
@pytest.mark.asyncio
@ -1770,6 +1780,211 @@ async def test_permission_check_view_query_actions(action):
}
@pytest.mark.asyncio
async def test_permission_check_explains_specificity_for_hypothetical_actor():
ds = Datasette(
config={
"permissions": {"view-table": {"id": "alice"}},
"databases": {
"analytics": {
"permissions": {"view-table": False},
"tables": {
"public": {"permissions": {"view-table": {"id": "alice"}}}
},
}
},
}
)
ds.root_enabled = True
await ds.invoke_startup()
def path_for(child):
return "/-/check.json?" + urllib.parse.urlencode(
{
"action": "view-table",
"parent": "analytics",
"child": child,
"actor": json.dumps({"id": "alice"}),
}
)
public_response = await ds.client.get(path_for("public"), actor={"id": "root"})
assert public_response.status_code == 200
public = public_response.json()
assert public["actor"] == {"id": "alice"}
assert public["allowed"] is True
assert public["explanation"]["allowed"] is True
assert public["explanation"]["winning_scope"] == "resource"
public_rules = public["explanation"]["matched_rules"]
assert any(
rule["scope"] == "resource" and rule["effect"] == "allow" and rule["decisive"]
for rule in public_rules
)
assert any(
rule["scope"] == "parent"
and rule["effect"] == "deny"
and rule["ignored_because"] == "A more specific rule matched"
for rule in public_rules
)
private_response = await ds.client.get(path_for("private"), actor={"id": "root"})
assert private_response.status_code == 200
private = private_response.json()
assert private["allowed"] is False
assert private["explanation"]["allowed"] is False
assert private["explanation"]["winning_scope"] == "parent"
assert private["explanation"]["summary"].startswith("Denied by a parent-level rule")
@pytest.mark.asyncio
async def test_permission_check_explains_deny_wins_at_same_scope():
ds = Datasette(config={"permissions": {"view-table": {"id": "someone-else"}}})
ds.root_enabled = True
await ds.invoke_startup()
path = "/-/check.json?" + urllib.parse.urlencode(
{
"action": "view-table",
"parent": "analytics",
"child": "users",
"actor": json.dumps({"id": "alice"}),
}
)
response = await ds.client.get(path, actor={"id": "root"})
assert response.status_code == 200
data = response.json()
assert data["allowed"] is False
assert data["explanation"]["winning_scope"] == "global"
rules = data["explanation"]["matched_rules"]
assert any(rule["effect"] == "deny" and rule["decisive"] for rule in rules)
assert any(
rule["effect"] == "allow"
and rule["ignored_because"] == "A deny rule matched at the same scope"
for rule in rules
)
@pytest.mark.asyncio
async def test_permission_check_explains_default_deny():
ds = Datasette()
ds.root_enabled = True
await ds.invoke_startup()
path = "/-/check.json?" + urllib.parse.urlencode(
{
"action": "insert-row",
"parent": "analytics",
"child": "users",
"actor": json.dumps({"id": "alice"}),
}
)
response = await ds.client.get(path, actor={"id": "root"})
assert response.status_code == 200
data = response.json()
assert data["allowed"] is False
explanation = data["explanation"]
assert explanation["allowed"] is False
assert explanation["matched_rules"] == []
assert explanation["winning_scope"] is None
assert explanation["summary"] == (
"Denied because no permission rule matched this actor and resource."
)
@pytest.mark.asyncio
async def test_permission_check_explains_actor_restrictions():
ds = Datasette()
ds.root_enabled = True
await ds.invoke_startup()
restricted_actor = {
"id": "alice",
"_r": {"r": {"analytics": {"public": ["vt"]}}},
}
path = "/-/check.json?" + urllib.parse.urlencode(
{
"action": "view-table",
"parent": "analytics",
"child": "private",
"actor": json.dumps(restricted_actor),
}
)
response = await ds.client.get(path, actor={"id": "root"})
assert response.status_code == 200
data = response.json()
assert data["allowed"] is False
explanation = data["explanation"]
assert explanation["rule_allowed"] is True
assert explanation["restriction_allowed"] is False
assert explanation["allowed"] is False
assert explanation["restrictions"]
assert any(
restriction["allowed"] is False for restriction in explanation["restrictions"]
)
assert "actor's restrictions" in explanation["summary"]
@pytest.mark.asyncio
async def test_permission_check_explains_required_actions():
from datasette import hookimpl
from datasette.permissions import PermissionSQL
class StoreQueryPermissions:
@hookimpl
def permission_resources_sql(self, actor, action):
if not actor or actor.get("id") != "alice":
return None
if action == "store-query":
return PermissionSQL(
sql="SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'alice can store queries' AS reason"
)
if action == "execute-sql":
return PermissionSQL(
sql="SELECT 'analytics' AS parent, NULL AS child, 0 AS allow, 'alice cannot execute SQL' AS reason"
)
ds = Datasette()
ds.root_enabled = True
await ds.invoke_startup()
ds.pm.register(StoreQueryPermissions(), name="store-query-test")
path = "/-/check.json?" + urllib.parse.urlencode(
{
"action": "store-query",
"parent": "analytics",
"actor": json.dumps({"id": "alice"}),
}
)
response = await ds.client.get(path, actor={"id": "root"})
assert response.status_code == 200
data = response.json()
assert data["allowed"] is False
explanation = data["explanation"]
assert explanation["rule_allowed"] is True
assert explanation["required_actions"][0]["action"] == "execute-sql"
assert explanation["required_actions"][0]["allowed"] is False
assert explanation["summary"] == (
"Denied because store-query also requires execute-sql, which was denied."
)
@pytest.mark.asyncio
async def test_permission_check_hypothetical_actor_validation():
ds = Datasette()
ds.root_enabled = True
await ds.invoke_startup()
response = await ds.client.get(
"/-/check.json?action=view-instance&actor=not-json",
actor={"id": "root"},
)
assert response.status_code == 400
assert response.json()["error"].startswith("Invalid actor JSON:")
response = await ds.client.get(
"/-/check.json?action=view-instance&actor=%5B%5D",
actor={"id": "root"},
)
assert response.status_code == 400
assert response.json()["error"] == "actor must be a JSON object or null"
@pytest.mark.asyncio
async def test_root_allow_block_with_table_restricted_actor():
"""

View file

@ -1482,7 +1482,7 @@ async def test_plugin_is_installed():
datasette.pm.register(DummyPlugin(), name="DummyPlugin")
response = await datasette.client.get("/-/plugins.json")
assert response.status_code == 200
installed_plugins = {p["name"] for p in response.json()["plugins"]}
installed_plugins = {p["name"] for p in response.json()}
assert "DummyPlugin" in installed_plugins
finally:

View file

@ -94,83 +94,6 @@ async def test_queries_internal_table_schema():
]
@pytest.mark.asyncio
async def test_editor_schema_rich_completions():
from datasette.fixtures import TABLES
from datasette.views.query_helpers import _editor_schema
ds = Datasette(memory=True)
db = ds.add_memory_database("editor_schema")
await ds.invoke_startup()
await db.execute_write_script(TABLES)
await ds.refresh_schemas()
schema = await _editor_schema(ds, "editor_schema")
# Tables are plain lists of Completion objects carrying type + boost
attractions = schema["roadside_attractions"]
assert isinstance(attractions, list)
pk_completion = next(c for c in attractions if c["label"] == "pk")
assert pk_completion == {
"label": "pk",
"type": "property",
"boost": 10,
"detail": "INTEGER",
}
# Every column completion is boosted above bare keywords
assert all(c["boost"] == 10 and c["type"] == "property" for c in attractions)
# Views are wrapped in a self/children container labelled as a view
view = schema["paginated_view"]
assert view["self"] == {
"label": "paginated_view",
"type": "class",
"detail": "view",
}
child_labels = [c["label"] for c in view["children"]]
assert child_labels == ["content", "content_extra"]
# Column type inherited from the underlying table flows through as detail;
# the computed expression column has no declared type so carries no detail
content = next(c for c in view["children"] if c["label"] == "content")
assert content["detail"] == "TEXT"
content_extra = next(c for c in view["children"] if c["label"] == "content_extra")
assert "detail" not in content_extra
# Whole payload must be JSON-serializable
json.dumps(schema)
@pytest.mark.asyncio
async def test_database_page_editor_schema_permission_gated():
import secrets
from datasette.database import Database
from datasette.fixtures import TABLES
async def schema_for(datasette):
# Unique memory name so parallel instances don't share a backing DB
name = f"editor_gated_{secrets.token_hex(8)}"
db = datasette.add_database(
Database(datasette, memory_name=name), name="editor_gated"
)
await datasette.invoke_startup()
await db.execute_write_script(TABLES)
await datasette.refresh_schemas()
response = await datasette.client.get("/editor_gated.json")
assert response.status_code == 200
return response.json()["table_columns"]
# execute-sql allowed (default): rich schema is emitted
allowed = await schema_for(Datasette(memory=True))
assert isinstance(allowed["roadside_attractions"], list)
assert allowed["paginated_view"]["self"]["detail"] == "view"
# execute-sql denied: no schema leak, empty dict
denied = await schema_for(
Datasette(memory=True, settings={"default_allow_sql": False})
)
assert denied == {}
@pytest.mark.asyncio
async def test_add_get_and_remove_query():
ds = Datasette(memory=True)

View file

@ -1,5 +1,3 @@
import json
import pytest
import pytest_asyncio
from datasette.app import Datasette
@ -247,165 +245,3 @@ async def test_table_not_exists(schema_ds):
response = await schema_ds.client.get("/schema_public_db/nonexistent/-/schema.md")
assert response.status_code == 404
assert "not found" in response.text.lower()
# ---------------------------------------------------------------------------
# /<database>/-/editor-schema.json — neutral structured schema for SQL editors
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture(scope="module")
async def editor_schema_ds():
"""Datasette instance exercising the editor-schema endpoint.
- public db: tables + a view + an FTS table (hidden shadow tables)
- private db: gated behind view-database (allow root only)
- noexec db: view-database allowed for anyone, execute-sql denied
"""
ds = Datasette(
config={
"databases": {
"editor_private_db": {"allow": {"id": "root"}},
"editor_noexec_db": {
# Everyone may view the database, but nobody may run SQL
"allow_sql": {"id": "root"},
},
}
}
)
public_db = ds.add_memory_database("editor_public_db")
await public_db.execute_write(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
)
await public_db.execute_write(
"CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, body TEXT)"
)
await public_db.execute_write(
"CREATE VIEW recent_posts AS SELECT id, title FROM posts ORDER BY id DESC"
)
# An FTS table produces hidden shadow tables (users_fts_data, etc.)
await public_db.execute_write(
"CREATE VIRTUAL TABLE users_fts USING fts5(name, content=users)"
)
private_db = ds.add_memory_database("editor_private_db")
await private_db.execute_write(
"CREATE TABLE secret_data (id INTEGER PRIMARY KEY, value TEXT)"
)
noexec_db = ds.add_memory_database("editor_noexec_db")
await noexec_db.execute_write(
"CREATE TABLE locked (id INTEGER PRIMARY KEY, value TEXT)"
)
await ds.invoke_startup()
await ds.refresh_schemas()
return ds
@pytest.mark.asyncio
async def test_editor_schema_allowed_shape(editor_schema_ds):
"""Authorized fetch returns tables, columns, types and views in the
documented neutral shape."""
response = await editor_schema_ds.client.get(
"/editor_public_db/-/editor-schema.json"
)
assert response.status_code == 200
data = response.json()
assert data["database"] == "editor_public_db"
assert isinstance(data["tables"], list)
by_name = {t["name"]: t for t in data["tables"]}
# Regular table with columns + declared types
users = by_name["users"]
assert users["view"] is False
assert users["columns"] == [
{"name": "id", "type": "INTEGER"},
{"name": "name", "type": "TEXT"},
]
posts = by_name["posts"]
assert posts["view"] is False
assert {c["name"] for c in posts["columns"]} == {"id", "title", "body"}
# View is flagged and carries its real columns
view = by_name["recent_posts"]
assert view["view"] is True
assert [c["name"] for c in view["columns"]] == ["id", "title"]
# Whole payload is JSON-serializable and every entry matches the shape
for table in data["tables"]:
assert set(table) == {"name", "view", "columns"}
for column in table["columns"]:
assert set(column) == {"name", "type"}
@pytest.mark.asyncio
async def test_editor_schema_excludes_hidden_tables(editor_schema_ds):
"""FTS shadow tables (hidden_table_names) must not appear."""
response = await editor_schema_ds.client.get(
"/editor_public_db/-/editor-schema.json"
)
assert response.status_code == 200
names = {t["name"] for t in response.json()["tables"]}
assert not any("_fts_" in name or name.endswith("_fts") for name in names), names
# Sanity: the visible objects are still there
assert {"users", "posts", "recent_posts"} <= names
@pytest.mark.asyncio
async def test_editor_schema_denied_view_database_403_no_leak(editor_schema_ds):
"""Anonymous user lacking view-database gets a 403 that leaks no names."""
response = await editor_schema_ds.client.get(
"/editor_private_db/-/editor-schema.json"
)
assert response.status_code == 403
body = response.text
assert "secret_data" not in body
data = response.json()
assert data["ok"] is False
assert "secret_data" not in json.dumps(data)
# The permitted actor can read it
response = await editor_schema_ds.client.get(
"/editor_private_db/-/editor-schema.json", actor={"id": "root"}
)
assert response.status_code == 200
names = {t["name"] for t in response.json()["tables"]}
assert "secret_data" in names
@pytest.mark.asyncio
async def test_editor_schema_denied_execute_sql_403_no_leak(editor_schema_ds):
"""A viewer who lacks execute-sql gets a 403 with no schema data."""
# Anonymous user may view editor_noexec_db but not run SQL against it
response = await editor_schema_ds.client.get(
"/editor_noexec_db/-/editor-schema.json"
)
assert response.status_code == 403
data = response.json()
assert data["ok"] is False
assert "tables" not in data
assert "locked" not in json.dumps(data)
# The actor granted execute-sql can read the schema
response = await editor_schema_ds.client.get(
"/editor_noexec_db/-/editor-schema.json", actor={"id": "root"}
)
assert response.status_code == 200
names = {t["name"] for t in response.json()["tables"]}
assert "locked" in names
@pytest.mark.asyncio
async def test_editor_schema_database_not_found(editor_schema_ds):
"""A non-existent database returns a 404 JSON error."""
response = await editor_schema_ds.client.get(
"/nonexistent_db/-/editor-schema.json"
)
assert response.status_code == 404
data = response.json()
assert data["ok"] is False
assert "not found" in data["error"].lower()

View file

@ -2,8 +2,8 @@
Tests for the canonical JSON success envelope.
Every JSON object returned by a Datasette endpoint on success should include
"ok": true. (Endpoints that return a top-level array are being converted to
objects separately - see /-/plugins, /-/databases, /-/actions.)
"ok": true. /-/plugins intentionally returns a top-level array instead, while
/-/databases and /-/actions use the object envelope.
"""
import pytest
@ -79,17 +79,16 @@ async def test_permissions_post_success_has_ok_true(ds_envelope):
@pytest.mark.asyncio
async def test_plugins_json_is_object(ds_client):
async def test_plugins_json_is_array(ds_client):
response = await ds_client.get("/-/plugins.json")
assert response.status_code == 200
data = response.json()
assert set(data.keys()) == {"ok", "plugins"}
assert data["ok"] is True
assert isinstance(data["plugins"], list)
assert isinstance(data, list)
assert all(isinstance(plugin, dict) for plugin in data)
# ?all=1 should include Datasette's default plugins in the same shape
response_all = await ds_client.get("/-/plugins.json?all=1")
all_plugins = response_all.json()["plugins"]
assert len(all_plugins) > len(data["plugins"])
all_plugins = response_all.json()
assert len(all_plugins) > len(data)
@pytest.mark.asyncio