Show multiple permission reasons as JSON arrays, refs #2531

- Modified /-/allowed to show all reasons that grant access to a resource
- Changed from MAX(reason) to json_group_array() in SQL to collect all reasons
- Reasons now displayed as JSON arrays in both HTML and JSON responses
- Only show Reason column to users with permissions-debug permission
- Removed obsolete "Source Plugin" column from /-/rules interface
- Updated allowed_resources_with_reasons() to parse and return reason lists
- Fixed alert() on /-/allowed by replacing with disabled input state
This commit is contained in:
Simon Willison 2025-10-25 21:24:05 -07:00
commit d769e97ab8
6 changed files with 40 additions and 21 deletions

View file

@ -1233,7 +1233,22 @@ class Datasette:
resources = [] resources = []
for row in result.rows: for row in result.rows:
resource = self.resource_for_action(action, parent=row[0], child=row[1]) resource = self.resource_for_action(action, parent=row[0], child=row[1])
reason = row[2] reason_json = row[2]
# Parse JSON array of reasons and filter out nulls
try:
import json
reasons_array = (
json.loads(reason_json) if isinstance(reason_json, str) else []
)
reasons_filtered = [r for r in reasons_array if r is not None]
# Store as list for multiple reasons, or keep empty list
reason = reasons_filtered
except (json.JSONDecodeError, TypeError):
# Fallback for backward compatibility
reason = [reason_json] if reason_json else []
resources.append(AllowedResource(resource=resource, reason=reason)) resources.append(AllowedResource(resource=resource, reason=reason))
return resources return resources

View file

@ -177,7 +177,12 @@ function displayResults(data) {
html += `<td>${escapeHtml(item.parent || '—')}</td>`; html += `<td>${escapeHtml(item.parent || '—')}</td>`;
html += `<td>${escapeHtml(item.child || '—')}</td>`; html += `<td>${escapeHtml(item.child || '—')}</td>`;
if (hasDebugPermission) { if (hasDebugPermission) {
html += `<td>${escapeHtml(item.reason || '—')}</td>`; // Display reason as JSON array
let reasonHtml = '—';
if (item.reason && Array.isArray(item.reason)) {
reasonHtml = `<code>${escapeHtml(JSON.stringify(item.reason))}</code>`;
}
html += `<td>${reasonHtml}</td>`;
} }
html += '</tr>'; html += '</tr>';
} }

View file

@ -154,7 +154,6 @@ function displayResults(data) {
html += '<th>Parent</th>'; html += '<th>Parent</th>';
html += '<th>Child</th>'; html += '<th>Child</th>';
html += '<th>Reason</th>'; html += '<th>Reason</th>';
html += '<th>Source Plugin</th>';
html += '</tr></thead>'; html += '</tr></thead>';
html += '<tbody>'; html += '<tbody>';
@ -170,7 +169,6 @@ function displayResults(data) {
html += `<td>${escapeHtml(item.parent || '—')}</td>`; html += `<td>${escapeHtml(item.parent || '—')}</td>`;
html += `<td>${escapeHtml(item.child || '—')}</td>`; html += `<td>${escapeHtml(item.child || '—')}</td>`;
html += `<td>${escapeHtml(item.reason || '—')}</td>`; html += `<td>${escapeHtml(item.reason || '—')}</td>`;
html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;
html += '</tr>'; html += '</tr>';
} }

View file

@ -261,8 +261,8 @@ async def _build_single_action_sql(
" SELECT b.parent, b.child,", " SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,", " 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,", " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,", " json_group_array(CASE WHEN ar.allow = 0 THEN ar.reason END) AS deny_reasons,",
" MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason", " json_group_array(CASE WHEN ar.allow = 1 THEN ar.reason END) AS allow_reasons",
" FROM base b", " FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child", " LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
" GROUP BY b.parent, b.child", " GROUP BY b.parent, b.child",
@ -271,8 +271,8 @@ async def _build_single_action_sql(
" SELECT b.parent, b.child,", " SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,", " 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,", " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,", " json_group_array(CASE WHEN ar.allow = 0 THEN ar.reason END) AS deny_reasons,",
" MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason", " json_group_array(CASE WHEN ar.allow = 1 THEN ar.reason END) AS allow_reasons",
" FROM base b", " FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL", " LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child", " GROUP BY b.parent, b.child",
@ -281,8 +281,8 @@ async def _build_single_action_sql(
" SELECT b.parent, b.child,", " SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,", " 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,", " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,", " json_group_array(CASE WHEN ar.allow = 0 THEN ar.reason END) AS deny_reasons,",
" MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason", " json_group_array(CASE WHEN ar.allow = 1 THEN ar.reason END) AS allow_reasons",
" FROM base b", " FROM base b",
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL", " LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child", " GROUP BY b.parent, b.child",
@ -363,13 +363,13 @@ async def _build_single_action_sql(
" ELSE 0", " ELSE 0",
" END AS is_allowed,", " END AS is_allowed,",
" CASE", " CASE",
" WHEN cl.any_deny = 1 THEN cl.deny_reason", " WHEN cl.any_deny = 1 THEN cl.deny_reasons",
" WHEN cl.any_allow = 1 THEN cl.allow_reason", " WHEN cl.any_allow = 1 THEN cl.allow_reasons",
" WHEN pl.any_deny = 1 THEN pl.deny_reason", " WHEN pl.any_deny = 1 THEN pl.deny_reasons",
" WHEN pl.any_allow = 1 THEN pl.allow_reason", " WHEN pl.any_allow = 1 THEN pl.allow_reasons",
" WHEN gl.any_deny = 1 THEN gl.deny_reason", " WHEN gl.any_deny = 1 THEN gl.deny_reasons",
" WHEN gl.any_allow = 1 THEN gl.allow_reason", " WHEN gl.any_allow = 1 THEN gl.allow_reasons",
" ELSE 'default deny'", " ELSE '[]'",
" END AS reason", " END AS reason",
] ]
) )

View file

@ -327,7 +327,7 @@ class AllowedResourcesView(BaseView):
"resource": resource_path, "resource": resource_path,
} }
# Add reason if we have it # Add reason if we have it (it's already a list from allowed_resources_with_reasons)
if reason is not None: if reason is not None:
row["reason"] = reason row["reason"] = reason

View file

@ -163,10 +163,11 @@ async def test_allowed_resources_with_reasons(test_ds):
# Check we can access both resource and reason # Check we can access both resource and reason
for item in allowed: for item in allowed:
assert isinstance(item.resource, TableResource) assert isinstance(item.resource, TableResource)
assert isinstance(item.reason, str) assert isinstance(item.reason, list)
if item.resource.parent == "analytics": if item.resource.parent == "analytics":
# Should mention parent-level reason # Should mention parent-level reason in at least one of the reasons
assert "analyst access" in item.reason.lower() reasons_text = " ".join(item.reason).lower()
assert "analyst access" in reasons_text
finally: finally:
pm.unregister(plugin, name="test_plugin") pm.unregister(plugin, name="test_plugin")