issue_comments: 1301635340
This data as json
html_url | issue_url | id | node_id | user | created_at | updated_at | author_association | body | reactions | issue | performed_via_github_app |
---|---|---|---|---|---|---|---|---|---|---|---|
https://github.com/simonw/datasette/issues/1881#issuecomment-1301635340 | https://api.github.com/repos/simonw/datasette/issues/1881 | 1301635340 | IC_kwDOBm6k_c5NlWEM | 9599 | 2022-11-03T04:46:41Z | 2022-11-03T04:46:41Z | OWNER | Built this prototype: ![prototype](https://user-images.githubusercontent.com/9599/199649219-f146e43b-bfb5-45e6-9777-956f21a79887.gif) In building it I realized I needed to know which permissions took a table, a database, both or neither. So I had to bake that into the code. Here's the prototype so far (which includes a prototype of the logic for the `_r` field on actor, see #1855): ```diff diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 32b0c758..f68aa38f 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -6,8 +6,8 @@ import json import time -@hookimpl(tryfirst=True) -def permission_allowed(datasette, actor, action, resource): +@hookimpl(tryfirst=True, specname="permission_allowed") +def permission_allowed_default(datasette, actor, action, resource): async def inner(): if action in ( "permissions-debug", @@ -57,6 +57,44 @@ def permission_allowed(datasette, actor, action, resource): return inner +@hookimpl(specname="permission_allowed") +def permission_allowed_actor_restrictions(actor, action, resource): + if actor is None: + return None + _r = actor.get("_r") + if not _r: + # No restrictions, so we have no opinion + return None + action_initials = "".join([word[0] for word in action.split("-")]) + # If _r is defined then we use those to further restrict the actor + # Crucially, we only use this to say NO (return False) - we never + # use it to return YES (True) because that might over-ride other + # restrictions placed on this actor + all_allowed = _r.get("a") + if all_allowed is not None: + assert isinstance(all_allowed, list) + if action_initials in all_allowed: + return None + # How about for the current database? + if action in ("view-database", "view-database-download", "execute-sql"): + database_allowed = _r.get("d", {}).get(resource) + if database_allowed is not None: + assert isinstance(database_allowed, list) + if action_initials in database_allowed: + return None + # Or the current table? That's any time the resource is (database, table) + if not isinstance(resource, str) and len(resource) == 2: + database, table = resource + table_allowed = _r.get("t", {}).get(database, {}).get(table) + # TODO: What should this do for canned queries? + if table_allowed is not None: + assert isinstance(table_allowed, list) + if action_initials in table_allowed: + return None + # This action is not specifically allowed, so reject it + return False + + @hookimpl def actor_from_request(datasette, request): prefix = "dstok_" diff --git a/datasette/templates/allow_debug.html b/datasette/templates/allow_debug.html index 0f1b30f0..ae43f0f5 100644 --- a/datasette/templates/allow_debug.html +++ b/datasette/templates/allow_debug.html @@ -35,7 +35,7 @@ p.message-warning { <p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p> -<form action="{{ urls.path('-/allow-debug') }}" method="get"> +<form action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em"> <div class="two-col"> <p><label>Allow block</label></p> <textarea name="allow">{{ allow_input }}</textarea> @@ -55,4 +55,82 @@ p.message-warning { {% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %} +<h2>Test permission check</h2> + +<p>This tool lets you simulate an actor and a permission check for that actor.</p> + +<form action="{{ urls.path('-/allow-debug') }}" id="debug-post" method="post" style="margin-bottom: 1em"> + <input type="hidden" name="csrftoken" value="{{ csrftoken() }}"> + <div class="two-col"> + <p><label>Actor</label></p> + <textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea> + </div> + <div class="two-col" style="vertical-align: top"> + <p><label>Permission check</label></p> + <p><label for="permission" style="display:block">Permission</label> + <select name="permission" id="permission"> + {% for permission in [ + "view-instance", + "view-database", + "view-database-download", + "view-table", + "view-query", + "insert-row", + "delete-row", + "drop-table", + "execute-sql", + "permissions-debug", + "debug-menu"] %} + <option value="{{ permission }}">{{ permission }}</option> + {% endfor %} + </select> + <p><label for="resource_1">Database name</label><input type="text" id="resource_1" name="resource_1"></p> + <p><label for="resource_2">Table or query name</label><input type="text" id="resource_2" name="resource_2"></p> + </div> + <div style="margin-top: 1em;"> + <input type="submit" value="Simulate permission check"> + </div> +</form> + +<script> +var rawPerms = {{ permissions|tojson }}; +var permissions = Object.fromEntries(rawPerms.map(([label, abbr, needs_resource_1, needs_resource_2, def]) => [label, {needs_resource_1, needs_resource_2, def}])) +var permissionSelect = document.getElementById('permission'); +var resource1 = document.getElementById('resource_1'); +var resource2 = document.getElementById('resource_2'); +function updateResourceVisibility() { + var permission = permissionSelect.value; + var {needs_resource_1, needs_resource_2} = permissions[permission]; + if (needs_resource_1) { + resource1.closest('p').style.display = 'block'; + } else { + resource1.closest('p').style.display = 'none'; + } + if (needs_resource_2) { + resource2.closest('p').style.display = 'block'; + } else { + resource2.closest('p').style.display = 'none'; + } +} +permissionSelect.addEventListener('change', updateResourceVisibility); +updateResourceVisibility(); + +// When #debug-post form is submitted, use fetch() to POST data +var debugPost = document.getElementById('debug-post'); +debugPost.addEventListener('submit', function(ev) { + ev.preventDefault(); + var formData = new FormData(debugPost); + console.log(formData); + fetch(debugPost.action, { + method: 'POST', + body: new URLSearchParams(formData), + }).then(function(response) { + return response.json(); + }).then(function(data) { + alert(JSON.stringify(data, null, 4)); + }); +}); +</script> + + {% endblock %} diff --git a/datasette/views/special.py b/datasette/views/special.py index 9922a621..d46fc280 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -1,6 +1,8 @@ import json +from datasette.permissions import PERMISSIONS from datasette.utils.asgi import Response, Forbidden from datasette.utils import actor_matches_allow, add_cors_headers +from datasette.permissions import PERMISSIONS from .base import BaseView import secrets import time @@ -138,9 +140,34 @@ class AllowDebugView(BaseView): "error": "\n\n".join(errors) if errors else "", "actor_input": actor_input, "allow_input": allow_input, + "permissions": PERMISSIONS, }, ) + async def post(self, request): + vars = await request.post_vars() + actor = json.loads(vars["actor"]) + permission = vars["permission"] + resource_1 = vars["resource_1"] + resource_2 = vars["resource_2"] + resource = [] + if resource_1: + resource.append(resource_1) + if resource_2: + resource.append(resource_2) + resource = tuple(resource) + result = await self.ds.permission_allowed( + actor, permission, resource, default="USE_DEFAULT" + ) + return Response.json( + { + "actor": actor, + "permission": permission, + "resource": resource, + "result": result, + } + ) + class MessagesDebugView(BaseView): name = "messages_debug" ``` | {"total_count": 0, "+1": 0, "-1": 0, "laugh": 0, "hooray": 0, "confused": 0, "heart": 0, "rocket": 0, "eyes": 0} | 1434094365 |