mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
New allowed_resources_sql plugin hook and debug tools (#2505)
* allowed_resources_sql plugin hook and infrastructure * New methods for checking permissions with the new system * New /-/allowed and /-/check and /-/rules special endpoints Still needs to be integrated more deeply into Datasette, especially for listing visible tables. Refs: #2502 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
85da8474d4
commit
27084caa04
20 changed files with 3381 additions and 27 deletions
160
datasette/app.py
160
datasette/app.py
|
|
@ -49,6 +49,9 @@ from .views.special import (
|
|||
AllowDebugView,
|
||||
PermissionsDebugView,
|
||||
MessagesDebugView,
|
||||
AllowedResourcesView,
|
||||
PermissionRulesView,
|
||||
PermissionCheckView,
|
||||
)
|
||||
from .views.table import (
|
||||
TableInsertView,
|
||||
|
|
@ -111,6 +114,8 @@ from .tracer import AsgiTracer
|
|||
from .plugins import pm, DEFAULT_PLUGINS, get_plugins
|
||||
from .version import __version__
|
||||
|
||||
from .utils.permissions import build_rules_union, PluginSQL
|
||||
|
||||
app_root = Path(__file__).parent.parent
|
||||
|
||||
# https://github.com/simonw/datasette/issues/283#issuecomment-781591015
|
||||
|
|
@ -1030,6 +1035,149 @@ class Datasette:
|
|||
)
|
||||
return result
|
||||
|
||||
async def allowed_resources_sql(
|
||||
self, actor: dict | None, action: str
|
||||
) -> tuple[str, dict]:
|
||||
"""Combine permission_resources_sql PluginSQL blocks into a UNION query.
|
||||
|
||||
Returns a (sql, params) tuple suitable for execution against SQLite.
|
||||
"""
|
||||
plugin_blocks: List[PluginSQL] = []
|
||||
for block in pm.hook.permission_resources_sql(
|
||||
datasette=self,
|
||||
actor=actor,
|
||||
action=action,
|
||||
):
|
||||
block = await await_me_maybe(block)
|
||||
if block is None:
|
||||
continue
|
||||
if isinstance(block, (list, tuple)):
|
||||
candidates = block
|
||||
else:
|
||||
candidates = [block]
|
||||
for candidate in candidates:
|
||||
if candidate is None:
|
||||
continue
|
||||
if not isinstance(candidate, PluginSQL):
|
||||
continue
|
||||
plugin_blocks.append(candidate)
|
||||
|
||||
actor_id = actor.get("id") if actor else None
|
||||
sql, params = build_rules_union(
|
||||
actor=str(actor_id) if actor_id is not None else "",
|
||||
plugins=plugin_blocks,
|
||||
)
|
||||
return sql, params
|
||||
|
||||
async def permission_allowed_2(
|
||||
self, actor, action, resource=None, *, default=DEFAULT_NOT_SET
|
||||
):
|
||||
"""Permission check backed by permission_resources_sql rules."""
|
||||
|
||||
if default is DEFAULT_NOT_SET and action in self.permissions:
|
||||
default = self.permissions[action].default
|
||||
|
||||
if isinstance(actor, dict) or actor is None:
|
||||
actor_dict = actor
|
||||
else:
|
||||
actor_dict = {"id": actor}
|
||||
actor_id = actor_dict.get("id") if actor_dict else None
|
||||
|
||||
candidate_parent = None
|
||||
candidate_child = None
|
||||
if isinstance(resource, str):
|
||||
candidate_parent = resource
|
||||
elif isinstance(resource, (tuple, list)) and len(resource) == 2:
|
||||
candidate_parent, candidate_child = resource
|
||||
elif resource is not None:
|
||||
raise TypeError("resource must be None, str, or (parent, child) tuple")
|
||||
|
||||
union_sql, union_params = await self.allowed_resources_sql(actor_dict, action)
|
||||
|
||||
query = f"""
|
||||
WITH rules AS (
|
||||
{union_sql}
|
||||
),
|
||||
candidate AS (
|
||||
SELECT :cand_parent AS parent, :cand_child AS child
|
||||
),
|
||||
matched AS (
|
||||
SELECT
|
||||
r.allow,
|
||||
r.reason,
|
||||
r.source_plugin,
|
||||
CASE
|
||||
WHEN r.child IS NOT NULL THEN 2
|
||||
WHEN r.parent IS NOT NULL THEN 1
|
||||
ELSE 0
|
||||
END AS depth
|
||||
FROM rules r
|
||||
JOIN candidate c
|
||||
ON (r.parent IS NULL OR r.parent = c.parent)
|
||||
AND (r.child IS NULL OR r.child = c.child)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY
|
||||
depth DESC,
|
||||
CASE WHEN allow = 0 THEN 0 ELSE 1 END,
|
||||
source_plugin
|
||||
) AS rn
|
||||
FROM matched
|
||||
),
|
||||
winner AS (
|
||||
SELECT allow, reason, source_plugin, depth
|
||||
FROM ranked
|
||||
WHERE rn = 1
|
||||
)
|
||||
SELECT allow, reason, source_plugin, depth FROM winner
|
||||
"""
|
||||
|
||||
params = {
|
||||
**union_params,
|
||||
"cand_parent": candidate_parent,
|
||||
"cand_child": candidate_child,
|
||||
}
|
||||
|
||||
rows = await self.get_internal_database().execute(query, params)
|
||||
row = rows.first()
|
||||
|
||||
reason = None
|
||||
source_plugin = None
|
||||
depth = None
|
||||
used_default = False
|
||||
|
||||
if row is None:
|
||||
result = default
|
||||
used_default = True
|
||||
else:
|
||||
allow = row["allow"]
|
||||
reason = row["reason"]
|
||||
source_plugin = row["source_plugin"]
|
||||
depth = row["depth"]
|
||||
if allow is None:
|
||||
result = default
|
||||
used_default = True
|
||||
else:
|
||||
result = bool(allow)
|
||||
|
||||
self._permission_checks.append(
|
||||
{
|
||||
"when": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"actor": actor,
|
||||
"action": action,
|
||||
"resource": resource,
|
||||
"used_default": used_default,
|
||||
"result": result,
|
||||
"reason": reason,
|
||||
"source_plugin": source_plugin,
|
||||
"depth": depth,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def ensure_permissions(
|
||||
self,
|
||||
actor: dict,
|
||||
|
|
@ -1586,6 +1734,18 @@ class Datasette:
|
|||
PermissionsDebugView.as_view(self),
|
||||
r"/-/permissions$",
|
||||
)
|
||||
add_route(
|
||||
AllowedResourcesView.as_view(self),
|
||||
r"/-/allowed(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
PermissionRulesView.as_view(self),
|
||||
r"/-/rules(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
PermissionCheckView.as_view(self),
|
||||
r"/-/check(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
MessagesDebugView.as_view(self),
|
||||
r"/-/messages$",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from datasette import hookimpl, Permission
|
||||
from datasette.utils.permissions import PluginSQL
|
||||
from datasette.utils import actor_matches_allow
|
||||
import itsdangerous
|
||||
import time
|
||||
from typing import Union, Tuple
|
||||
|
||||
|
||||
@hookimpl
|
||||
|
|
@ -172,6 +172,163 @@ def permission_allowed_default(datasette, actor, action, resource):
|
|||
return inner
|
||||
|
||||
|
||||
@hookimpl
|
||||
async def permission_resources_sql(datasette, actor, action):
|
||||
rules: list[PluginSQL] = []
|
||||
|
||||
config_rules = await _config_permission_rules(datasette, actor, action)
|
||||
rules.extend(config_rules)
|
||||
|
||||
default_allow_actions = {
|
||||
"view-instance",
|
||||
"view-database",
|
||||
"view-table",
|
||||
"execute-sql",
|
||||
}
|
||||
if action in default_allow_actions:
|
||||
reason = f"default allow for {action}".replace("'", "''")
|
||||
sql = (
|
||||
"SELECT NULL AS parent, NULL AS child, 1 AS allow, " f"'{reason}' AS reason"
|
||||
)
|
||||
rules.append(
|
||||
PluginSQL(
|
||||
source="default_permissions",
|
||||
sql=sql,
|
||||
params={},
|
||||
)
|
||||
)
|
||||
|
||||
if not rules:
|
||||
return None
|
||||
if len(rules) == 1:
|
||||
return rules[0]
|
||||
return rules
|
||||
|
||||
|
||||
async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]:
|
||||
config = datasette.config or {}
|
||||
|
||||
if actor is None:
|
||||
actor_dict: dict | None = None
|
||||
elif isinstance(actor, dict):
|
||||
actor_dict = actor
|
||||
else:
|
||||
actor_lookup = await datasette.actors_from_ids([actor])
|
||||
actor_dict = actor_lookup.get(actor) or {"id": actor}
|
||||
|
||||
def evaluate(allow_block):
|
||||
if allow_block is None:
|
||||
return None
|
||||
return actor_matches_allow(actor_dict, allow_block)
|
||||
|
||||
rows = []
|
||||
|
||||
def add_row(parent, child, result, scope):
|
||||
if result is None:
|
||||
return
|
||||
rows.append(
|
||||
(
|
||||
parent,
|
||||
child,
|
||||
bool(result),
|
||||
f"config {'allow' if result else 'deny'} {scope}",
|
||||
)
|
||||
)
|
||||
|
||||
root_perm = (config.get("permissions") or {}).get(action)
|
||||
add_row(None, None, evaluate(root_perm), f"permissions for {action}")
|
||||
|
||||
for db_name, db_config in (config.get("databases") or {}).items():
|
||||
db_perm = (db_config.get("permissions") or {}).get(action)
|
||||
add_row(
|
||||
db_name, None, evaluate(db_perm), f"permissions for {action} on {db_name}"
|
||||
)
|
||||
|
||||
for table_name, table_config in (db_config.get("tables") or {}).items():
|
||||
table_perm = (table_config.get("permissions") or {}).get(action)
|
||||
add_row(
|
||||
db_name,
|
||||
table_name,
|
||||
evaluate(table_perm),
|
||||
f"permissions for {action} on {db_name}/{table_name}",
|
||||
)
|
||||
|
||||
if action == "view-table":
|
||||
table_allow = (table_config or {}).get("allow")
|
||||
add_row(
|
||||
db_name,
|
||||
table_name,
|
||||
evaluate(table_allow),
|
||||
f"allow for {action} on {db_name}/{table_name}",
|
||||
)
|
||||
|
||||
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||
query_perm = (query_config.get("permissions") or {}).get(action)
|
||||
add_row(
|
||||
db_name,
|
||||
query_name,
|
||||
evaluate(query_perm),
|
||||
f"permissions for {action} on {db_name}/{query_name}",
|
||||
)
|
||||
if action == "view-query":
|
||||
query_allow = (query_config or {}).get("allow")
|
||||
add_row(
|
||||
db_name,
|
||||
query_name,
|
||||
evaluate(query_allow),
|
||||
f"allow for {action} on {db_name}/{query_name}",
|
||||
)
|
||||
|
||||
if action == "view-database":
|
||||
db_allow = db_config.get("allow")
|
||||
add_row(
|
||||
db_name, None, evaluate(db_allow), f"allow for {action} on {db_name}"
|
||||
)
|
||||
|
||||
if action == "execute-sql":
|
||||
db_allow_sql = db_config.get("allow_sql")
|
||||
add_row(db_name, None, evaluate(db_allow_sql), f"allow_sql for {db_name}")
|
||||
|
||||
if action == "view-instance":
|
||||
allow_block = config.get("allow")
|
||||
add_row(None, None, evaluate(allow_block), "allow for view-instance")
|
||||
|
||||
if action == "view-table":
|
||||
# Tables handled in loop
|
||||
pass
|
||||
|
||||
if action == "view-query":
|
||||
# Queries handled in loop
|
||||
pass
|
||||
|
||||
if action == "execute-sql":
|
||||
allow_sql = config.get("allow_sql")
|
||||
add_row(None, None, evaluate(allow_sql), "allow_sql")
|
||||
|
||||
if action == "view-database":
|
||||
# already handled per-database
|
||||
pass
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
parts = []
|
||||
params = {}
|
||||
for idx, (parent, child, allow, reason) in enumerate(rows):
|
||||
key = f"cfg_{idx}"
|
||||
parts.append(
|
||||
f"SELECT :{key}_parent AS parent, :{key}_child AS child, :{key}_allow AS allow, :{key}_reason AS reason"
|
||||
)
|
||||
params[f"{key}_parent"] = parent
|
||||
params[f"{key}_child"] = child
|
||||
params[f"{key}_allow"] = 1 if allow else 0
|
||||
params[f"{key}_reason"] = reason
|
||||
|
||||
sql = "\nUNION ALL\n".join(parts)
|
||||
print(sql, params)
|
||||
return [PluginSQL(source="config_permissions", sql=sql, params=params)]
|
||||
|
||||
|
||||
async def _resolve_config_permissions_blocks(datasette, actor, action, resource):
|
||||
# Check custom permissions: blocks
|
||||
config = datasette.config or {}
|
||||
|
|
@ -277,7 +434,7 @@ def restrictions_allow_action(
|
|||
datasette: "Datasette",
|
||||
restrictions: dict,
|
||||
action: str,
|
||||
resource: Union[str, Tuple[str, str]],
|
||||
resource: str | tuple[str, str],
|
||||
):
|
||||
"Do these restrictions allow the requested action against the requested resource?"
|
||||
if action == "view-instance":
|
||||
|
|
|
|||
|
|
@ -115,6 +115,18 @@ def permission_allowed(datasette, actor, action, resource):
|
|||
"""Check if actor is allowed to perform this action - return True, False or None"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def permission_resources_sql(datasette, actor, action):
|
||||
"""Return SQL query fragments for permission checks on resources.
|
||||
|
||||
Returns None, a PluginSQL object, or a list of PluginSQL objects.
|
||||
Each PluginSQL contains SQL that should return rows with columns:
|
||||
parent (str|None), child (str|None), allow (int), reason (str).
|
||||
|
||||
Used to efficiently check permissions across multiple resources at once.
|
||||
"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def canned_queries(datasette, database, actor):
|
||||
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
|
||||
|
|
|
|||
145
datasette/templates/_permission_ui_styles.html
Normal file
145
datasette/templates/_permission_ui_styles.html
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<style>
|
||||
.permission-form {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 1.5em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.form-section label {
|
||||
display: block;
|
||||
margin-bottom: 0.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-section input[type="text"],
|
||||
.form-section select {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 0.5em;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.form-section input[type="text"]:focus,
|
||||
.form-section select:focus {
|
||||
outline: 2px solid #0066cc;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
.form-section small {
|
||||
display: block;
|
||||
margin-top: 0.3em;
|
||||
color: #666;
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.submit-btn {
|
||||
padding: 0.6em 1.5em;
|
||||
font-size: 1em;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.submit-btn:hover {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.results-container {
|
||||
margin-top: 2em;
|
||||
}
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.results-count {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.results-table th {
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
.results-table td {
|
||||
padding: 0.75em;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.results-table tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.results-table tr.allow-row {
|
||||
background-color: #f1f8f4;
|
||||
}
|
||||
.results-table tr.allow-row:hover {
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
.results-table tr.deny-row {
|
||||
background-color: #fef5f5;
|
||||
}
|
||||
.results-table tr.deny-row:hover {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
.resource-path {
|
||||
font-family: monospace;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 1.5em;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
}
|
||||
.pagination a {
|
||||
padding: 0.5em 1em;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.pagination a:hover {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
.pagination span {
|
||||
color: #666;
|
||||
}
|
||||
.no-results {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.error-message {
|
||||
padding: 1em;
|
||||
background-color: #ffebee;
|
||||
border: 2px solid #f44336;
|
||||
border-radius: 5px;
|
||||
color: #c62828;
|
||||
}
|
||||
.loading {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
294
datasette/templates/debug_allowed.html
Normal file
294
datasette/templates/debug_allowed.html
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Allowed Resources{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Allowed Resources</h1>
|
||||
|
||||
<p>Use this tool to check which resources the current actor is allowed to access for a given permission action. It queries the <code>/-/allowed.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="allowed-form">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for permission_name in supported_actions %}
|
||||
<option value="{{ permission_name }}">{{ permission_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>Only certain actions are supported by this endpoint</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="parent">Filter by parent (optional):</label>
|
||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||
<small>Filter results to a specific parent resource</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="child">Filter by child (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>Filter results to a specific child resource (requires parent)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Check Allowed Resources</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="results-container" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h2>Results</h2>
|
||||
<div class="results-count" id="results-count"></div>
|
||||
</div>
|
||||
|
||||
<div id="results-content"></div>
|
||||
|
||||
<div id="pagination" class="pagination"></div>
|
||||
|
||||
<details style="margin-top: 2em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('allowed-form');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const resultsContent = document.getElementById('results-content');
|
||||
const resultsCount = document.getElementById('results-count');
|
||||
const pagination = document.getElementById('pagination');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
let currentData = null;
|
||||
|
||||
// Populate form from URL parameters on page load
|
||||
function populateFormFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
document.getElementById('action').value = action;
|
||||
}
|
||||
|
||||
const parent = params.get('parent');
|
||||
if (parent) {
|
||||
document.getElementById('parent').value = parent;
|
||||
}
|
||||
|
||||
const child = params.get('child');
|
||||
if (child) {
|
||||
document.getElementById('child').value = child;
|
||||
}
|
||||
|
||||
const pageSize = params.get('page_size');
|
||||
if (pageSize) {
|
||||
document.getElementById('page_size').value = pageSize;
|
||||
}
|
||||
|
||||
const page = params.get('page');
|
||||
|
||||
// If parameters are present, automatically fetch results
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Update URL with current form values and page
|
||||
function updateURL(page = 1) {
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (page > 1) {
|
||||
params.set('page', page.toString());
|
||||
}
|
||||
|
||||
const newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
|
||||
window.history.pushState({}, '', newURL);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
updateURL(1);
|
||||
await fetchResults(1, false);
|
||||
});
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('popstate', () => {
|
||||
populateFormFromURL();
|
||||
});
|
||||
|
||||
// Populate form on initial load
|
||||
populateFormFromURL();
|
||||
|
||||
async function fetchResults(page = 1, updateHistory = true) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Loading...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
currentData = data;
|
||||
displayResults(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
displayError({ error: error.message });
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Check Allowed Resources';
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
// Update count
|
||||
resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total resources (page ${data.page})`;
|
||||
|
||||
// Display results table
|
||||
if (data.items.length === 0) {
|
||||
resultsContent.innerHTML = '<div class="no-results">No allowed resources found for this action.</div>';
|
||||
} else {
|
||||
let html = '<table class="results-table">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Resource Path</th>';
|
||||
html += '<th>Parent</th>';
|
||||
html += '<th>Child</th>';
|
||||
html += '<th>Reason</th>';
|
||||
html += '<th>Source Plugin</th>';
|
||||
html += '</tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
||||
for (const item of data.items) {
|
||||
html += '<tr>';
|
||||
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.reason || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
resultsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
pagination.innerHTML = '';
|
||||
if (data.previous_url || data.next_url) {
|
||||
if (data.previous_url) {
|
||||
const prevLink = document.createElement('a');
|
||||
prevLink.href = '#';
|
||||
prevLink.textContent = '← Previous';
|
||||
prevLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
updateURL(data.page - 1);
|
||||
fetchResults(data.page - 1, false);
|
||||
});
|
||||
pagination.appendChild(prevLink);
|
||||
}
|
||||
|
||||
const pageInfo = document.createElement('span');
|
||||
pageInfo.textContent = `Page ${data.page}`;
|
||||
pagination.appendChild(pageInfo);
|
||||
|
||||
if (data.next_url) {
|
||||
const nextLink = document.createElement('a');
|
||||
nextLink.href = '#';
|
||||
nextLink.textContent = 'Next →';
|
||||
nextLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
updateURL(data.page + 1);
|
||||
fetchResults(data.page + 1, false);
|
||||
});
|
||||
pagination.appendChild(nextLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Update raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
// Scroll to results
|
||||
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
resultsCount.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
|
||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
childInput.addEventListener('focus', () => {
|
||||
if (!parentInput.value) {
|
||||
alert('Please specify a parent resource first before filtering by child resource.');
|
||||
parentInput.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
350
datasette/templates/debug_check.html
Normal file
350
datasette/templates/debug_check.html
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission Check{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
<style>
|
||||
.form-section {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.form-section label {
|
||||
display: block;
|
||||
margin-bottom: 0.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-section input[type="text"],
|
||||
.form-section select {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 0.5em;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.form-section input[type="text"]:focus,
|
||||
.form-section select:focus {
|
||||
outline: 2px solid #0066cc;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
.form-section small {
|
||||
display: block;
|
||||
margin-top: 0.3em;
|
||||
color: #666;
|
||||
}
|
||||
#output {
|
||||
margin-top: 2em;
|
||||
padding: 1em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
#output.allowed {
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
#output.denied {
|
||||
background-color: #ffebee;
|
||||
border: 2px solid #f44336;
|
||||
}
|
||||
#output h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
#output .result-badge {
|
||||
display: inline-block;
|
||||
padding: 0.3em 0.8em;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
#output .allowed-badge {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
#output .denied-badge {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
.details-section {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.details-section dt {
|
||||
font-weight: bold;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.details-section dd {
|
||||
margin-left: 1em;
|
||||
}
|
||||
#submit-btn {
|
||||
padding: 0.6em 1.5em;
|
||||
font-size: 1em;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#submit-btn:hover {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
#submit-btn:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Permission Check</h1>
|
||||
|
||||
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<form id="check-form" class="core">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for permission_name in sorted_permissions %}
|
||||
<option value="{{ permission_name }}">{{ permission_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>The permission action to check</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="parent">Parent resource (optional):</label>
|
||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||
<small>For database-level permissions, specify the database name</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="child">Child resource (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>For table-level permissions, specify the table name (requires parent)</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submit-btn">Check Permission</button>
|
||||
</form>
|
||||
|
||||
<div id="output" style="display: none;">
|
||||
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||
|
||||
<dl class="details-section">
|
||||
<dt>Action:</dt>
|
||||
<dd id="result-action"></dd>
|
||||
|
||||
<dt>Resource Path:</dt>
|
||||
<dd id="result-resource"></dd>
|
||||
|
||||
<dt>Actor ID:</dt>
|
||||
<dd id="result-actor"></dd>
|
||||
|
||||
<div id="additional-details"></div>
|
||||
</dl>
|
||||
|
||||
<details style="margin-top: 1em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('check-form');
|
||||
const output = document.getElementById('output');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
// Populate form from URL parameters on page load
|
||||
function populateFormFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
document.getElementById('action').value = action;
|
||||
}
|
||||
|
||||
const parent = params.get('parent');
|
||||
if (parent) {
|
||||
document.getElementById('parent').value = parent;
|
||||
}
|
||||
|
||||
const child = params.get('child');
|
||||
if (child) {
|
||||
document.getElementById('child').value = child;
|
||||
}
|
||||
|
||||
// If parameters are present, automatically submit the form
|
||||
if (action) {
|
||||
performCheck();
|
||||
}
|
||||
}
|
||||
|
||||
// Update URL with current form values
|
||||
function updateURL() {
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
|
||||
window.history.pushState({}, '', newURL);
|
||||
}
|
||||
|
||||
async function performCheck() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Checking...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResult(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Check Permission';
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
updateURL();
|
||||
await performCheck();
|
||||
});
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('popstate', () => {
|
||||
populateFormFromURL();
|
||||
});
|
||||
|
||||
// Populate form on initial load
|
||||
populateFormFromURL();
|
||||
|
||||
function displayResult(data) {
|
||||
output.style.display = 'block';
|
||||
|
||||
// Set badge and styling
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
if (data.allowed) {
|
||||
output.className = 'allowed';
|
||||
resultBadge.className = 'result-badge allowed-badge';
|
||||
resultBadge.textContent = 'ALLOWED ✓';
|
||||
} else {
|
||||
output.className = 'denied';
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'DENIED ✗';
|
||||
}
|
||||
|
||||
// Basic details
|
||||
document.getElementById('result-action').textContent = data.action || 'N/A';
|
||||
document.getElementById('result-resource').textContent = data.resource?.path || '/';
|
||||
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
|
||||
|
||||
// Additional details
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '';
|
||||
|
||||
if (data.reason !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Reason:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.reason || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.source_plugin !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Source Plugin:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.source_plugin || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.used_default !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Used Default:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.used_default ? 'Yes' : 'No';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.depth !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Depth:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.depth;
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
// Raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
// Scroll to output
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
output.style.display = 'block';
|
||||
output.className = 'denied';
|
||||
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'ERROR';
|
||||
|
||||
document.getElementById('result-action').textContent = 'N/A';
|
||||
document.getElementById('result-resource').textContent = 'N/A';
|
||||
document.getElementById('result-actor').textContent = 'N/A';
|
||||
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
childInput.addEventListener('focus', () => {
|
||||
if (!parentInput.value) {
|
||||
alert('Please specify a parent resource first before adding a child resource.');
|
||||
parentInput.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
268
datasette/templates/debug_rules.html
Normal file
268
datasette/templates/debug_rules.html
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission Rules{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Permission Rules</h1>
|
||||
|
||||
<p>Use this tool to view the permission rules that allow the current actor to access resources for a given permission action. It queries the <code>/-/rules.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="rules-form">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for permission_name in sorted_permissions %}
|
||||
<option value="{{ permission_name }}">{{ permission_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>The permission action to check</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">View Permission Rules</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="results-container" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h2>Results</h2>
|
||||
<div class="results-count" id="results-count"></div>
|
||||
</div>
|
||||
|
||||
<div id="results-content"></div>
|
||||
|
||||
<div id="pagination" class="pagination"></div>
|
||||
|
||||
<details style="margin-top: 2em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('rules-form');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const resultsContent = document.getElementById('results-content');
|
||||
const resultsCount = document.getElementById('results-count');
|
||||
const pagination = document.getElementById('pagination');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
let currentData = null;
|
||||
|
||||
// Populate form from URL parameters on page load
|
||||
function populateFormFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
document.getElementById('action').value = action;
|
||||
}
|
||||
|
||||
const pageSize = params.get('page_size');
|
||||
if (pageSize) {
|
||||
document.getElementById('page_size').value = pageSize;
|
||||
}
|
||||
|
||||
const page = params.get('page');
|
||||
|
||||
// If parameters are present, automatically fetch results
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Update URL with current form values and page
|
||||
function updateURL(page = 1) {
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (page > 1) {
|
||||
params.set('page', page.toString());
|
||||
}
|
||||
|
||||
const newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
|
||||
window.history.pushState({}, '', newURL);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
updateURL(1);
|
||||
await fetchResults(1, false);
|
||||
});
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('popstate', () => {
|
||||
populateFormFromURL();
|
||||
});
|
||||
|
||||
// Populate form on initial load
|
||||
populateFormFromURL();
|
||||
|
||||
async function fetchResults(page = 1, updateHistory = true) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Loading...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
currentData = data;
|
||||
displayResults(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
displayError({ error: error.message });
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'View Permission Rules';
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
// Update count
|
||||
resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total rules (page ${data.page})`;
|
||||
|
||||
// Display results table
|
||||
if (data.items.length === 0) {
|
||||
resultsContent.innerHTML = '<div class="no-results">No permission rules found for this action.</div>';
|
||||
} else {
|
||||
let html = '<table class="results-table">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Effect</th>';
|
||||
html += '<th>Resource Path</th>';
|
||||
html += '<th>Parent</th>';
|
||||
html += '<th>Child</th>';
|
||||
html += '<th>Reason</th>';
|
||||
html += '<th>Source Plugin</th>';
|
||||
html += '</tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
||||
for (const item of data.items) {
|
||||
const rowClass = item.allow ? 'allow-row' : 'deny-row';
|
||||
const effectBadge = item.allow
|
||||
? '<span style="background: #4caf50; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;">ALLOW</span>'
|
||||
: '<span style="background: #f44336; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;">DENY</span>';
|
||||
|
||||
html += `<tr class="${rowClass}">`;
|
||||
html += `<td>${effectBadge}</td>`;
|
||||
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.reason || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
resultsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
pagination.innerHTML = '';
|
||||
if (data.previous_url || data.next_url) {
|
||||
if (data.previous_url) {
|
||||
const prevLink = document.createElement('a');
|
||||
prevLink.href = '#';
|
||||
prevLink.textContent = '← Previous';
|
||||
prevLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
updateURL(data.page - 1);
|
||||
fetchResults(data.page - 1, false);
|
||||
});
|
||||
pagination.appendChild(prevLink);
|
||||
}
|
||||
|
||||
const pageInfo = document.createElement('span');
|
||||
pageInfo.textContent = `Page ${data.page}`;
|
||||
pagination.appendChild(pageInfo);
|
||||
|
||||
if (data.next_url) {
|
||||
const nextLink = document.createElement('a');
|
||||
nextLink.href = '#';
|
||||
nextLink.textContent = 'Next →';
|
||||
nextLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
updateURL(data.page + 1);
|
||||
fetchResults(data.page + 1, false);
|
||||
});
|
||||
pagination.appendChild(nextLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Update raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
// Scroll to results
|
||||
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
resultsCount.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
|
||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
244
datasette/utils/permissions.py
Normal file
244
datasette/utils/permissions.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# perm_utils.py
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
||||
import sqlite3
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Plugin interface & utilities
|
||||
# -----------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginSQL:
|
||||
"""
|
||||
A plugin contributes SQL that yields:
|
||||
parent TEXT NULL,
|
||||
child TEXT NULL,
|
||||
allow INTEGER, -- 1 allow, 0 deny
|
||||
reason TEXT
|
||||
"""
|
||||
|
||||
source: str # identifier used for auditing (e.g., plugin name)
|
||||
sql: str # SQL that SELECTs the 4 columns above
|
||||
params: Dict[str, Any] # bound params for the SQL (values only; no ':' prefix)
|
||||
|
||||
|
||||
def _namespace_params(i: int, params: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Rewrite parameter placeholders to distinct names per plugin block.
|
||||
Returns (rewritten_sql, namespaced_params).
|
||||
"""
|
||||
|
||||
replacements = {key: f"{key}_{i}" for key in params.keys()}
|
||||
|
||||
def rewrite(s: str) -> str:
|
||||
for key in sorted(replacements.keys(), key=len, reverse=True):
|
||||
s = s.replace(f":{key}", f":{replacements[key]}")
|
||||
return s
|
||||
|
||||
namespaced: Dict[str, Any] = {}
|
||||
for key, value in params.items():
|
||||
namespaced[replacements[key]] = value
|
||||
return rewrite, namespaced
|
||||
|
||||
|
||||
PluginProvider = Callable[[str], PluginSQL]
|
||||
PluginOrFactory = Union[PluginSQL, PluginProvider]
|
||||
|
||||
|
||||
def build_rules_union(
|
||||
actor: str, plugins: Sequence[PluginSQL]
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Compose plugin SQL into a UNION ALL with namespaced parameters.
|
||||
|
||||
Returns:
|
||||
union_sql: a SELECT with columns (parent, child, allow, reason, source_plugin)
|
||||
params: dict of bound parameters including :actor and namespaced plugin params
|
||||
"""
|
||||
parts: List[str] = []
|
||||
params: Dict[str, Any] = {"actor": actor}
|
||||
|
||||
for i, p in enumerate(plugins):
|
||||
rewrite, ns_params = _namespace_params(i, p.params)
|
||||
sql_block = rewrite(p.sql)
|
||||
params.update(ns_params)
|
||||
|
||||
parts.append(
|
||||
f"""
|
||||
SELECT parent, child, allow, reason, '{p.source}' AS source_plugin FROM (
|
||||
{sql_block}
|
||||
)
|
||||
""".strip()
|
||||
)
|
||||
|
||||
if not parts:
|
||||
# Empty UNION that returns no rows
|
||||
union_sql = "SELECT NULL parent, NULL child, NULL allow, NULL reason, 'none' source_plugin WHERE 0"
|
||||
else:
|
||||
union_sql = "\nUNION ALL\n".join(parts)
|
||||
|
||||
return union_sql, params
|
||||
|
||||
|
||||
# -----------------------------------------------
|
||||
# Core resolvers (no temp tables, no custom UDFs)
|
||||
# -----------------------------------------------
|
||||
|
||||
|
||||
async def resolve_permissions_from_catalog(
|
||||
db,
|
||||
actor: str,
|
||||
plugins: Sequence[PluginOrFactory],
|
||||
action: str,
|
||||
candidate_sql: str,
|
||||
candidate_params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
implicit_deny: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
|
||||
|
||||
Expectations:
|
||||
- candidate_sql SELECTs: parent TEXT, child TEXT
|
||||
(Use child=NULL for parent-scoped actions like "execute-sql".)
|
||||
- *db* exposes: rows = await db.execute(sql, params)
|
||||
where rows is an iterable of sqlite3.Row
|
||||
- plugins are either PluginSQL objects or callables accepting (action: str)
|
||||
and returning PluginSQL instances selecting (parent, child, allow, reason)
|
||||
|
||||
Decision policy:
|
||||
1) Specificity first: child (depth=2) > parent (depth=1) > root (depth=0)
|
||||
2) Within the same depth: deny (0) beats allow (1)
|
||||
3) If no matching rule:
|
||||
- implicit_deny=True -> treat as allow=0, reason='implicit deny'
|
||||
- implicit_deny=False -> allow=None, reason=None
|
||||
|
||||
Returns: list of dict rows
|
||||
- parent, child, allow, reason, source_plugin, depth
|
||||
- resource (rendered "/parent/child" or "/parent" or "/")
|
||||
"""
|
||||
resolved_plugins: List[PluginSQL] = []
|
||||
for plugin in plugins:
|
||||
if callable(plugin) and not isinstance(plugin, PluginSQL):
|
||||
resolved = plugin(action) # type: ignore[arg-type]
|
||||
else:
|
||||
resolved = plugin # type: ignore[assignment]
|
||||
if not isinstance(resolved, PluginSQL):
|
||||
raise TypeError("Plugin providers must return PluginSQL instances")
|
||||
resolved_plugins.append(resolved)
|
||||
|
||||
union_sql, rule_params = build_rules_union(actor, resolved_plugins)
|
||||
all_params = {
|
||||
**(candidate_params or {}),
|
||||
**rule_params,
|
||||
"actor": actor,
|
||||
"action": action,
|
||||
}
|
||||
|
||||
sql = f"""
|
||||
WITH
|
||||
cands AS (
|
||||
{candidate_sql}
|
||||
),
|
||||
rules AS (
|
||||
{union_sql}
|
||||
),
|
||||
matched AS (
|
||||
SELECT
|
||||
c.parent, c.child,
|
||||
r.allow, r.reason, r.source_plugin,
|
||||
CASE
|
||||
WHEN r.child IS NOT NULL THEN 2 -- child-level (most specific)
|
||||
WHEN r.parent IS NOT NULL THEN 1 -- parent-level
|
||||
ELSE 0 -- root/global
|
||||
END AS depth
|
||||
FROM cands c
|
||||
JOIN rules r
|
||||
ON (r.parent IS NULL OR r.parent = c.parent)
|
||||
AND (r.child IS NULL OR r.child = c.child)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY parent, child
|
||||
ORDER BY
|
||||
depth DESC, -- specificity first
|
||||
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- deny over allow at same depth
|
||||
source_plugin -- stable tie-break
|
||||
) AS rn
|
||||
FROM matched
|
||||
),
|
||||
winner AS (
|
||||
SELECT parent, child,
|
||||
allow, reason, source_plugin, depth
|
||||
FROM ranked WHERE rn = 1
|
||||
)
|
||||
SELECT
|
||||
c.parent, c.child,
|
||||
COALESCE(w.allow, CASE WHEN :implicit_deny THEN 0 ELSE NULL END) AS allow,
|
||||
COALESCE(w.reason, CASE WHEN :implicit_deny THEN 'implicit deny' ELSE NULL END) AS reason,
|
||||
w.source_plugin,
|
||||
COALESCE(w.depth, -1) AS depth,
|
||||
:action AS action,
|
||||
CASE
|
||||
WHEN c.parent IS NULL THEN '/'
|
||||
WHEN c.child IS NULL THEN '/' || c.parent
|
||||
ELSE '/' || c.parent || '/' || c.child
|
||||
END AS resource
|
||||
FROM cands c
|
||||
LEFT JOIN winner w
|
||||
ON ((w.parent = c.parent) OR (w.parent IS NULL AND c.parent IS NULL))
|
||||
AND ((w.child = c.child ) OR (w.child IS NULL AND c.child IS NULL))
|
||||
ORDER BY c.parent, c.child
|
||||
"""
|
||||
|
||||
rows_iter: Iterable[sqlite3.Row] = await db.execute(
|
||||
sql,
|
||||
{**all_params, "implicit_deny": 1 if implicit_deny else 0},
|
||||
)
|
||||
return [dict(r) for r in rows_iter]
|
||||
|
||||
|
||||
async def resolve_permissions_with_candidates(
|
||||
db,
|
||||
actor: str,
|
||||
plugins: Sequence[PluginOrFactory],
|
||||
candidates: List[Tuple[str, Optional[str]]],
|
||||
action: str,
|
||||
*,
|
||||
implicit_deny: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Resolve permissions without any external candidate table by embedding
|
||||
the candidates as a UNION of parameterized SELECTs in a CTE.
|
||||
|
||||
candidates: list of (parent, child) where child can be None for parent-scoped actions.
|
||||
"""
|
||||
# Build a small CTE for candidates.
|
||||
cand_rows_sql: List[str] = []
|
||||
cand_params: Dict[str, Any] = {}
|
||||
for i, (parent, child) in enumerate(candidates):
|
||||
pkey = f"cand_p_{i}"
|
||||
ckey = f"cand_c_{i}"
|
||||
cand_params[pkey] = parent
|
||||
cand_params[ckey] = child
|
||||
cand_rows_sql.append(f"SELECT :{pkey} AS parent, :{ckey} AS child")
|
||||
candidate_sql = (
|
||||
"\nUNION ALL\n".join(cand_rows_sql)
|
||||
if cand_rows_sql
|
||||
else "SELECT NULL AS parent, NULL AS child WHERE 0"
|
||||
)
|
||||
|
||||
return await resolve_permissions_from_catalog(
|
||||
db,
|
||||
actor,
|
||||
plugins,
|
||||
action,
|
||||
candidate_sql=candidate_sql,
|
||||
candidate_params=cand_params,
|
||||
implicit_deny=implicit_deny,
|
||||
)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
|
|
@ -173,6 +174,24 @@ class BaseView:
|
|||
headers=headers,
|
||||
)
|
||||
|
||||
async def respond_json_or_html(self, request, data, filename):
|
||||
"""Return JSON or HTML with pretty JSON depending on format parameter."""
|
||||
as_format = request.url_vars.get("format")
|
||||
if as_format:
|
||||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.json(data, headers=headers)
|
||||
else:
|
||||
return await self.render(
|
||||
["show_json.html"],
|
||||
request=request,
|
||||
context={
|
||||
"filename": filename,
|
||||
"data_json": json.dumps(data, indent=4, default=repr),
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def as_view(cls, *class_args, **class_kwargs):
|
||||
async def view(request, send):
|
||||
|
|
|
|||
|
|
@ -1,17 +1,32 @@
|
|||
import json
|
||||
import logging
|
||||
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
||||
from datasette.utils.asgi import Response, Forbidden
|
||||
from datasette.utils import (
|
||||
actor_matches_allow,
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
tilde_encode,
|
||||
tilde_decode,
|
||||
)
|
||||
from datasette.utils.permissions import PluginSQL, resolve_permissions_from_catalog
|
||||
from datasette.plugins import pm
|
||||
from .base import BaseView, View
|
||||
import secrets
|
||||
import urllib
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resource_path(parent, child):
|
||||
if parent is None:
|
||||
return "/"
|
||||
if child is None:
|
||||
return f"/{parent}"
|
||||
return f"/{parent}/{child}"
|
||||
|
||||
|
||||
class JsonDataView(BaseView):
|
||||
name = "json_data"
|
||||
|
||||
|
|
@ -30,32 +45,13 @@ class JsonDataView(BaseView):
|
|||
self.permission = permission
|
||||
|
||||
async def get(self, request):
|
||||
as_format = request.url_vars["format"]
|
||||
if self.permission:
|
||||
await self.ds.ensure_permissions(request.actor, [self.permission])
|
||||
if self.needs_request:
|
||||
data = self.data_callback(request)
|
||||
else:
|
||||
data = self.data_callback()
|
||||
if as_format:
|
||||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response(
|
||||
json.dumps(data, default=repr),
|
||||
content_type="application/json; charset=utf-8",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
else:
|
||||
return await self.render(
|
||||
["show_json.html"],
|
||||
request=request,
|
||||
context={
|
||||
"filename": self.filename,
|
||||
"data_json": json.dumps(data, indent=4, default=repr),
|
||||
},
|
||||
)
|
||||
return await self.respond_json_or_html(request, data, self.filename)
|
||||
|
||||
|
||||
class PatternPortfolioView(View):
|
||||
|
|
@ -187,6 +183,402 @@ class PermissionsDebugView(BaseView):
|
|||
)
|
||||
|
||||
|
||||
class AllowedResourcesView(BaseView):
|
||||
name = "allowed"
|
||||
has_json_alternate = False
|
||||
|
||||
CANDIDATE_SQL = {
|
||||
"view-table": (
|
||||
"SELECT database_name AS parent, table_name AS child FROM catalog_tables",
|
||||
{},
|
||||
),
|
||||
"view-database": (
|
||||
"SELECT database_name AS parent, NULL AS child FROM catalog_databases",
|
||||
{},
|
||||
),
|
||||
"view-instance": ("SELECT NULL AS parent, NULL AS child", {}),
|
||||
"execute-sql": (
|
||||
"SELECT database_name AS parent, NULL AS child FROM catalog_databases",
|
||||
{},
|
||||
),
|
||||
}
|
||||
|
||||
async def get(self, request):
|
||||
await self.ds.refresh_schemas()
|
||||
|
||||
# Check if user has permissions-debug (to show sensitive fields)
|
||||
has_debug_permission = await self.ds.permission_allowed(
|
||||
request.actor, "permissions-debug"
|
||||
)
|
||||
|
||||
# Check if this is a request for JSON (has .json extension)
|
||||
as_format = request.url_vars.get("format")
|
||||
|
||||
if not as_format:
|
||||
# Render the HTML form (even if query parameters are present)
|
||||
return await self.render(
|
||||
["debug_allowed.html"],
|
||||
request,
|
||||
{
|
||||
"supported_actions": sorted(self.CANDIDATE_SQL.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
# JSON API - action parameter is required
|
||||
action = request.args.get("action")
|
||||
if not action:
|
||||
return Response.json({"error": "action parameter is required"}, status=400)
|
||||
if action not in self.ds.permissions:
|
||||
return Response.json({"error": f"Unknown action: {action}"}, status=404)
|
||||
if action not in self.CANDIDATE_SQL:
|
||||
return Response.json(
|
||||
{"error": f"Action '{action}' is not supported by this endpoint"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
actor = request.actor if isinstance(request.actor, dict) else None
|
||||
parent_filter = request.args.get("parent")
|
||||
child_filter = request.args.get("child")
|
||||
if child_filter and not parent_filter:
|
||||
return Response.json(
|
||||
{"error": "parent must be provided when child is specified"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
page = int(request.args.get("page", "1"))
|
||||
page_size = int(request.args.get("page_size", "50"))
|
||||
except ValueError:
|
||||
return Response.json(
|
||||
{"error": "page and page_size must be integers"}, status=400
|
||||
)
|
||||
if page < 1:
|
||||
return Response.json({"error": "page must be >= 1"}, status=400)
|
||||
if page_size < 1:
|
||||
return Response.json({"error": "page_size must be >= 1"}, status=400)
|
||||
max_page_size = 200
|
||||
if page_size > max_page_size:
|
||||
page_size = max_page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
candidate_sql, candidate_params = self.CANDIDATE_SQL[action]
|
||||
|
||||
db = self.ds.get_internal_database()
|
||||
required_tables = set()
|
||||
if "catalog_tables" in candidate_sql:
|
||||
required_tables.add("catalog_tables")
|
||||
if "catalog_databases" in candidate_sql:
|
||||
required_tables.add("catalog_databases")
|
||||
|
||||
for table in required_tables:
|
||||
if not await db.table_exists(table):
|
||||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.json(
|
||||
{
|
||||
"action": action,
|
||||
"actor_id": (actor or {}).get("id") if actor else None,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": 0,
|
||||
"items": [],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
plugins = []
|
||||
for block in pm.hook.permission_resources_sql(
|
||||
datasette=self.ds,
|
||||
actor=actor,
|
||||
action=action,
|
||||
):
|
||||
block = await await_me_maybe(block)
|
||||
if block is None:
|
||||
continue
|
||||
if isinstance(block, (list, tuple)):
|
||||
candidates = block
|
||||
else:
|
||||
candidates = [block]
|
||||
for candidate in candidates:
|
||||
if candidate is None:
|
||||
continue
|
||||
if not isinstance(candidate, PluginSQL):
|
||||
logger.warning(
|
||||
"Skipping permission_resources_sql result %r from plugin; expected PluginSQL",
|
||||
candidate,
|
||||
)
|
||||
continue
|
||||
plugins.append(candidate)
|
||||
|
||||
actor_id = actor.get("id") if actor else None
|
||||
rows = await resolve_permissions_from_catalog(
|
||||
db,
|
||||
actor=str(actor_id) if actor_id is not None else "",
|
||||
plugins=plugins,
|
||||
action=action,
|
||||
candidate_sql=candidate_sql,
|
||||
candidate_params=candidate_params,
|
||||
implicit_deny=True,
|
||||
)
|
||||
|
||||
allowed_rows = [row for row in rows if row["allow"] == 1]
|
||||
if parent_filter is not None:
|
||||
allowed_rows = [
|
||||
row for row in allowed_rows if row["parent"] == parent_filter
|
||||
]
|
||||
if child_filter is not None:
|
||||
allowed_rows = [row for row in allowed_rows if row["child"] == child_filter]
|
||||
total = len(allowed_rows)
|
||||
paged_rows = allowed_rows[offset : offset + page_size]
|
||||
|
||||
items = []
|
||||
for row in paged_rows:
|
||||
item = {
|
||||
"parent": row["parent"],
|
||||
"child": row["child"],
|
||||
"resource": row["resource"],
|
||||
}
|
||||
# Only include sensitive fields if user has permissions-debug
|
||||
if has_debug_permission:
|
||||
item["reason"] = row["reason"]
|
||||
item["source_plugin"] = row["source_plugin"]
|
||||
items.append(item)
|
||||
|
||||
def build_page_url(page_number):
|
||||
pairs = []
|
||||
for key in request.args:
|
||||
if key in {"page", "page_size"}:
|
||||
continue
|
||||
for value in request.args.getlist(key):
|
||||
pairs.append((key, value))
|
||||
pairs.append(("page", str(page_number)))
|
||||
pairs.append(("page_size", str(page_size)))
|
||||
query = urllib.parse.urlencode(pairs)
|
||||
return f"{request.path}?{query}"
|
||||
|
||||
response = {
|
||||
"action": action,
|
||||
"actor_id": actor_id,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
if total > offset + page_size:
|
||||
response["next_url"] = build_page_url(page + 1)
|
||||
if page > 1:
|
||||
response["previous_url"] = build_page_url(page - 1)
|
||||
|
||||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.json(response, headers=headers)
|
||||
|
||||
|
||||
class PermissionRulesView(BaseView):
|
||||
name = "permission_rules"
|
||||
has_json_alternate = False
|
||||
|
||||
async def get(self, request):
|
||||
await self.ds.ensure_permissions(request.actor, ["view-instance"])
|
||||
if not await self.ds.permission_allowed(request.actor, "permissions-debug"):
|
||||
raise Forbidden("Permission denied")
|
||||
|
||||
# Check if this is a request for JSON (has .json extension)
|
||||
as_format = request.url_vars.get("format")
|
||||
|
||||
if not as_format:
|
||||
# Render the HTML form (even if query parameters are present)
|
||||
return await self.render(
|
||||
["debug_rules.html"],
|
||||
request,
|
||||
{
|
||||
"sorted_permissions": sorted(self.ds.permissions.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
# JSON API - action parameter is required
|
||||
action = request.args.get("action")
|
||||
if not action:
|
||||
return Response.json({"error": "action parameter is required"}, status=400)
|
||||
if action not in self.ds.permissions:
|
||||
return Response.json({"error": f"Unknown action: {action}"}, status=404)
|
||||
|
||||
actor = request.actor if isinstance(request.actor, dict) else None
|
||||
|
||||
try:
|
||||
page = int(request.args.get("page", "1"))
|
||||
page_size = int(request.args.get("page_size", "50"))
|
||||
except ValueError:
|
||||
return Response.json(
|
||||
{"error": "page and page_size must be integers"}, status=400
|
||||
)
|
||||
if page < 1:
|
||||
return Response.json({"error": "page must be >= 1"}, status=400)
|
||||
if page_size < 1:
|
||||
return Response.json({"error": "page_size must be >= 1"}, status=400)
|
||||
max_page_size = 200
|
||||
if page_size > max_page_size:
|
||||
page_size = max_page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
union_sql, union_params = await self.ds.allowed_resources_sql(actor, action)
|
||||
await self.ds.refresh_schemas()
|
||||
db = self.ds.get_internal_database()
|
||||
|
||||
count_query = f"""
|
||||
WITH rules AS (
|
||||
{union_sql}
|
||||
)
|
||||
SELECT COUNT(*) AS count
|
||||
FROM rules
|
||||
"""
|
||||
count_row = (await db.execute(count_query, union_params)).first()
|
||||
total = count_row["count"] if count_row else 0
|
||||
|
||||
data_query = f"""
|
||||
WITH rules AS (
|
||||
{union_sql}
|
||||
)
|
||||
SELECT parent, child, allow, reason, source_plugin
|
||||
FROM rules
|
||||
ORDER BY allow DESC, (parent IS NOT NULL), parent, child
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
params = {**union_params, "limit": page_size, "offset": offset}
|
||||
rows = await db.execute(data_query, params)
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
parent = row["parent"]
|
||||
child = row["child"]
|
||||
items.append(
|
||||
{
|
||||
"parent": parent,
|
||||
"child": child,
|
||||
"resource": _resource_path(parent, child),
|
||||
"allow": row["allow"],
|
||||
"reason": row["reason"],
|
||||
"source_plugin": row["source_plugin"],
|
||||
}
|
||||
)
|
||||
|
||||
def build_page_url(page_number):
|
||||
pairs = []
|
||||
for key in request.args:
|
||||
if key in {"page", "page_size"}:
|
||||
continue
|
||||
for value in request.args.getlist(key):
|
||||
pairs.append((key, value))
|
||||
pairs.append(("page", str(page_number)))
|
||||
pairs.append(("page_size", str(page_size)))
|
||||
query = urllib.parse.urlencode(pairs)
|
||||
return f"{request.path}?{query}"
|
||||
|
||||
response = {
|
||||
"action": action,
|
||||
"actor_id": (actor or {}).get("id") if actor else None,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
if total > offset + page_size:
|
||||
response["next_url"] = build_page_url(page + 1)
|
||||
if page > 1:
|
||||
response["previous_url"] = build_page_url(page - 1)
|
||||
|
||||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.json(response, headers=headers)
|
||||
|
||||
|
||||
class PermissionCheckView(BaseView):
|
||||
name = "permission_check"
|
||||
has_json_alternate = False
|
||||
|
||||
async def get(self, request):
|
||||
# Check if user has permissions-debug (to show sensitive fields)
|
||||
has_debug_permission = await self.ds.permission_allowed(
|
||||
request.actor, "permissions-debug"
|
||||
)
|
||||
|
||||
# Check if this is a request for JSON (has .json extension)
|
||||
as_format = request.url_vars.get("format")
|
||||
|
||||
if not as_format:
|
||||
# Render the HTML form (even if query parameters are present)
|
||||
return await self.render(
|
||||
["debug_check.html"],
|
||||
request,
|
||||
{
|
||||
"sorted_permissions": sorted(self.ds.permissions.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
# JSON API - action parameter is required
|
||||
action = request.args.get("action")
|
||||
if not action:
|
||||
return Response.json({"error": "action parameter is required"}, status=400)
|
||||
if action not in self.ds.permissions:
|
||||
return Response.json({"error": f"Unknown action: {action}"}, status=404)
|
||||
|
||||
parent = request.args.get("parent")
|
||||
child = request.args.get("child")
|
||||
if child and not parent:
|
||||
return Response.json(
|
||||
{"error": "parent is required when child is provided"}, status=400
|
||||
)
|
||||
|
||||
if parent and child:
|
||||
resource = (parent, child)
|
||||
elif parent:
|
||||
resource = parent
|
||||
else:
|
||||
resource = None
|
||||
|
||||
before_checks = len(self.ds._permission_checks)
|
||||
allowed = await self.ds.permission_allowed_2(request.actor, action, resource)
|
||||
|
||||
info = None
|
||||
if len(self.ds._permission_checks) > before_checks:
|
||||
for check in reversed(self.ds._permission_checks):
|
||||
if (
|
||||
check.get("actor") == request.actor
|
||||
and check.get("action") == action
|
||||
and check.get("resource") == resource
|
||||
):
|
||||
info = check
|
||||
break
|
||||
|
||||
response = {
|
||||
"action": action,
|
||||
"allowed": bool(allowed),
|
||||
"resource": {
|
||||
"parent": parent,
|
||||
"child": child,
|
||||
"path": _resource_path(parent, child),
|
||||
},
|
||||
}
|
||||
|
||||
if request.actor and "id" in request.actor:
|
||||
response["actor_id"] = request.actor["id"]
|
||||
|
||||
if info is not None:
|
||||
response["used_default"] = info.get("used_default")
|
||||
response["depth"] = info.get("depth")
|
||||
# Only include sensitive fields if user has permissions-debug
|
||||
if has_debug_permission:
|
||||
response["reason"] = info.get("reason")
|
||||
response["source_plugin"] = info.get("source_plugin")
|
||||
|
||||
return Response.json(response)
|
||||
|
||||
|
||||
class AllowDebugView(BaseView):
|
||||
name = "allow_debug"
|
||||
has_json_alternate = False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue