mirror of
https://github.com/simonw/datasette.git
synced 2026-10-04 23:56:58 +02:00
Explain permission decisions
This commit is contained in:
parent
db82123108
commit
8c42f79323
7 changed files with 803 additions and 167 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,54 @@
|
|||
{% 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>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
<style>
|
||||
#actor {
|
||||
box-sizing: border-box;
|
||||
font-family: monospace;
|
||||
min-height: 7em;
|
||||
padding: 0.5em;
|
||||
width: 100%;
|
||||
}
|
||||
#output {
|
||||
margin-top: 2em;
|
||||
padding: 1em;
|
||||
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 +61,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 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 +192,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 %}
|
||||
|
|
|
|||
|
|
@ -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" %}
|
||||
|
|
@ -43,12 +43,14 @@ textarea {
|
|||
{% 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">
|
||||
|
|
@ -125,7 +127,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 %},
|
||||
|
|
|
|||
|
|
@ -673,3 +673,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."
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ 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 +87,14 @@ 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 +143,59 @@ 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 +206,18 @@ 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 +1208,26 @@ 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 +1262,28 @@ 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -748,7 +748,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(
|
||||
|
|
@ -1734,6 +1739,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
|
||||
|
|
@ -1759,6 +1766,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():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue