Add PermissionCheck dataclass with parent/child fields, refs #2528

Instead of logging permission checks as dicts with a 'resource' key,
use a typed dataclass with separate parent and child fields.

Changes:
- Created PermissionCheck dataclass in app.py
- Updated permission check logging to use dataclass
- Updated PermissionsDebugView to use dataclass attributes
- Updated PermissionCheckView to check parent/child instead of resource
- Updated permissions_debug.html template to display parent/child
- Updated test expectations to use dataclass attributes

This provides better type safety and cleaner separation between
parent and child resource identifiers.
This commit is contained in:
Simon Willison 2025-10-25 09:59:21 -07:00
commit 10ea23a59c
4 changed files with 37 additions and 34 deletions

View file

@ -120,6 +120,17 @@ from .resources import InstanceResource, DatabaseResource, TableResource
app_root = Path(__file__).parent.parent app_root = Path(__file__).parent.parent
@dataclasses.dataclass
class PermissionCheck:
"""Represents a logged permission check for debugging purposes."""
when: str
actor: Optional[Dict[str, Any]]
action: str
parent: Optional[str]
child: Optional[str]
result: bool
# https://github.com/simonw/datasette/issues/283#issuecomment-781591015 # https://github.com/simonw/datasette/issues/283#issuecomment-781591015
SQLITE_LIMIT_ATTACHED = 10 SQLITE_LIMIT_ATTACHED = 10
@ -1287,25 +1298,15 @@ class Datasette:
result = False result = False
# Log the permission check for debugging # Log the permission check for debugging
# Convert Resource to old-style format for backward compatibility with debug tools
if resource.parent and resource.child:
old_style_resource = (resource.parent, resource.child)
elif resource.parent:
old_style_resource = resource.parent
else:
old_style_resource = None
self._permission_checks.append( self._permission_checks.append(
{ PermissionCheck(
"when": datetime.datetime.now(datetime.timezone.utc).isoformat(), when=datetime.datetime.now(datetime.timezone.utc).isoformat(),
"actor": actor, actor=actor,
"action": action, action=action,
"resource": old_style_resource, parent=resource.parent,
"result": result, child=resource.child,
"reason": None, # Not tracked in new system result=result,
"source_plugin": None, # Not tracked in new system )
"depth": None, # Not tracked in new system
}
) )
return result return result

View file

@ -133,8 +133,14 @@ debugPost.addEventListener('submit', function(ev) {
{% endif %} {% endif %}
</h2> </h2>
<p><strong>Actor:</strong> {{ check.actor|tojson }}</p> <p><strong>Actor:</strong> {{ check.actor|tojson }}</p>
{% if check.resource %} {% if check.parent %}
<p><strong>Resource:</strong> {{ check.resource }}</p> <p><strong>Resource:</strong>
{% if check.child %}
{{ check.parent }} / {{ check.child }}
{% else %}
{{ check.parent }}
{% endif %}
</p>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}

View file

@ -120,13 +120,13 @@ class PermissionsDebugView(BaseView):
permission_checks = [ permission_checks = [
check check
for check in permission_checks for check in permission_checks
if (check["actor"] or {}).get("id") != request.actor["id"] if (check.actor or {}).get("id") != request.actor["id"]
] ]
elif filter_ == "only-yours": elif filter_ == "only-yours":
permission_checks = [ permission_checks = [
check check
for check in permission_checks for check in permission_checks
if (check["actor"] or {}).get("id") == request.actor["id"] if (check.actor or {}).get("id") == request.actor["id"]
] ]
return await self.render( return await self.render(
["permissions_debug.html"], ["permissions_debug.html"],
@ -540,9 +540,10 @@ class PermissionCheckView(BaseView):
if len(self.ds._permission_checks) > before_checks: if len(self.ds._permission_checks) > before_checks:
for check in reversed(self.ds._permission_checks): for check in reversed(self.ds._permission_checks):
if ( if (
check.get("actor") == request.actor check.actor == request.actor
and check.get("action") == action and check.action == action
and check.get("resource") == resource and check.parent == parent
and check.child == child
): ):
info = check info = check
break break
@ -560,12 +561,6 @@ class PermissionCheckView(BaseView):
if request.actor and "id" in request.actor: if request.actor and "id" in request.actor:
response["actor_id"] = request.actor["id"] response["actor_id"] = request.actor["id"]
if info is not None:
response["depth"] = info.get("depth")
# Only include sensitive fields if user has permissions-debug
if has_debug_permission:
response["reason"] = info.get("reason")
return Response.json(response) return Response.json(response)

View file

@ -1259,9 +1259,10 @@ async def test_actor_restrictions(
"response_status": response.status_code, "response_status": response.status_code,
"checks": [ "checks": [
{ {
"action": check["action"], "action": check.action,
"resource": check["resource"], "parent": check.parent,
"result": check["result"], "child": check.child,
"result": check.result,
} }
for check in perms_ds._permission_checks for check in perms_ds._permission_checks
], ],