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 = []
for row in result.rows:
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))
return resources

View file

@ -177,7 +177,12 @@ function displayResults(data) {
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
html += `<td>${escapeHtml(item.child || '—')}</td>`;
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>';
}

View file

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

View file

@ -261,8 +261,8 @@ async def _build_single_action_sql(
" 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,",
" MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,",
" MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN 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",
@ -271,8 +271,8 @@ async def _build_single_action_sql(
" 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,",
" MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,",
" MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN 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",
@ -281,8 +281,8 @@ async def _build_single_action_sql(
" 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,",
" MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,",
" MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN 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",
@ -363,13 +363,13 @@ async def _build_single_action_sql(
" ELSE 0",
" END AS is_allowed,",
" CASE",
" WHEN cl.any_deny = 1 THEN cl.deny_reason",
" WHEN cl.any_allow = 1 THEN cl.allow_reason",
" WHEN pl.any_deny = 1 THEN pl.deny_reason",
" WHEN pl.any_allow = 1 THEN pl.allow_reason",
" WHEN gl.any_deny = 1 THEN gl.deny_reason",
" WHEN gl.any_allow = 1 THEN gl.allow_reason",
" ELSE 'default deny'",
" 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",
" ELSE '[]'",
" END AS reason",
]
)

View file

@ -327,7 +327,7 @@ class AllowedResourcesView(BaseView):
"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:
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
for item in allowed:
assert isinstance(item.resource, TableResource)
assert isinstance(item.reason, str)
assert isinstance(item.reason, list)
if item.resource.parent == "analytics":
# Should mention parent-level reason
assert "analyst access" in item.reason.lower()
# Should mention parent-level reason in at least one of the reasons
reasons_text = " ".join(item.reason).lower()
assert "analyst access" in reasons_text
finally:
pm.unregister(plugin, name="test_plugin")