2026-08-10T19:17:14 conversation: 01kzphq44kn5c4tj3fkh7ms9jc id: 01kzphq44pny2k61h1rqytyeh4
Model: lmstudio/meta/muse-glimmer (resolved: meta/muse-glimmer)
Prompt
how does auth work?
System
You are a coding agent working in /Users/simon/Dropbox/dev/datasette on Darwin. Today's date is 2026-08-10.
You help with software engineering tasks: fixing bugs, implementing features, refactoring, and answering questions about the code. You have tools for reading, searching and editing files and for running shell commands, all confined to the directory above.
Workflow:
- Explore before you change anything: use list_files and search_files to find the relevant code, and always read_file a file before editing it.
- Prefer edit_file (exact string replacement) over write_file for existing files, and keep edits minimal - match the style, naming and conventions of the surrounding code.
- Verify your work: after making changes, run the project's tests or another relevant command with execute_command and check the result.
- If a tool returns an error, read the message and correct your approach rather than repeating the same call.
Honesty matters more than appearing successful. If tests fail, report the failure and include the relevant output. If you cannot complete something, say so plainly. If the task is ambiguous, stop and ask rather than guessing.
Tools
CodingTools({"root": "."}):-
CodingTools_edit_file:
0c5e06b0ed6d4d87987157d1ec40ff219b19c41b25aea4c08587cdce568c130f
Replace an exact string in a file.old_string must match the file contents exactly (including whitespace) and must identify a unique location unless replace_all is true. Returns a diff of the change so it can be verified.
Arguments:{"path": {"type": "string"}, "old_string": {"type": "string"}, "new_string": {"type": "string"}, "replace_all": {"default": false, "type": "boolean"}} -
CodingTools_execute_command:
87b7dfec15f45f2fe80f0e7584916a06e2e8b0765125978b0279eefe144414fe
Run a shell command in the session root directory.Returns combined stdout and stderr followed by an Exit code line. timeout is in seconds (maximum 600); on timeout the whole process tree is killed.
Arguments:{"command": {"type": "string"}, "timeout": {"default": 120, "type": "integer"}} -
CodingTools_list_files:
4d779da979d758d08d20e797ed0a3ae43dad9258acbc042a8f999eeb01d958eb
List files matching a glob pattern, newest first.Skips hidden directories, node_modules, pycache and (in a git repository) anything covered by .gitignore. Returns at most 200 paths relative to the searched directory.
Arguments:{"pattern": {"default": "**/*", "type": "string"}, "path": {"default": ".", "type": "string"}} -
CodingTools_read_file:
d952137aaa58fa9d23863195293e92e96711422b524fab58d35eac0770b821e9
Read a text file, returning numbered lines like cat -n.Paths are relative to the session root. Use offset (0-based first line) and limit (max lines) to page through files too large to read in one call.
Arguments:{"path": {"type": "string"}, "offset": {"default": 0, "type": "integer"}, "limit": {"default": 2000, "type": "integer"}} -
CodingTools_search_files:
6b947859f5aaf573042944cb27b9517d5534bf2435fd651b8388f0f9386ad263
Search file contents for a regular expression.Returns matches as path:line_number:line, capped at max_results. Use glob (e.g. "*.py") to restrict which files are searched.
Arguments:{"pattern": {"type": "string"}, "path": {"default": ".", "type": "string"}, "glob": {"default": null, "type": "string"}, "max_results": {"default": 100, "type": "integer"}} -
CodingTools_write_file:
0678eb459804f184686f58c5f9936f6d60e69b7cb5c9187596df7935383efe64
Create or overwrite a file with the given content.Parent directories are created as needed. Prefer edit_file for modifying existing files.
Arguments:{"path": {"type": "string"}, "content": {"type": "string"}}
-
Reasoning
how does auth work?
We need explain auth in datasette. Explore code.
Response
Tool calls
- CodingTools_list_files:
Zi2VZTkkx3xzqABuk9FcMtx79gpTE9sX
pattern:
path:**/*auth*.
2026-08-10T19:17:24
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_list_files:
Zi2VZTkkx3xzqABuk9FcMtx79gpTE9sXtests/test_auth.py docs/authentication.rst datasette/actor_auth_cookie.py
Response
Tool calls
- CodingTools_list_files:
mkYUELgVQJcG8K1aMpOznIIZTq8rTEFV
pattern:
path:*datasette
2026-08-10T19:17:28
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_list_files:
mkYUELgVQJcG8K1aMpOznIIZTq8rTEFVversion.py write_sql.py url_builder.py tracer.py tokens.py template_contexts.py stored_queries.py resources.py renderer.py plugins.py permissions.py jump.py inspect.py hookspecs.py handle_exception.py forbidden.py fixtures.py filters.py facets.py extras.py events.py default_table_actions.py default_query_actions.py default_magic_parameters.py default_jump_items.py default_debug_menu.py default_database_actions.py default_column_types.py default_actions.py database.py csrf.py column_types.py cli.py blob_renderer.py app.py actor_auth_cookie.py _pytest_plugin.py __init__.py __main__.py sql_functions.py
Reasoning
The user asks how auth works. Likely want overview. Let's read files: docs/authentication.rst, datasette/permissions.py, actor_auth_cookie.py, tokens.py, app.py maybe.
Let's read authentication doc.
Response
Tool calls
- CodingTools_read_file:
izzJ3Uhp26dGZ3zDPmKVdpLTdPEb2Hdx
path:docs/authentication.rst
2026-08-10T19:17:35
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
izzJ3Uhp26dGZ3zDPmKVdpLTdPEb2Hdx1 .. _authentication: 2 3 ================================ 4 Authentication and permissions 5 ================================ 6 7 Datasette doesn't require authentication by default. Any visitor to a Datasette instance can explore the full data and execute read-only SQL queries. 8 9 Datasette can be configured to only allow authenticated users, or to control which databases, tables, and queries can be accessed by the public or by specific users. Datasette's plugin system can be used to add many different styles of authentication, such as user accounts, single sign-on or API keys. 10 11 .. _authentication_actor: 12 13 Actors 14 ====== 15 16 Through plugins, Datasette can support both authenticated users (with cookies) and authenticated API clients (via authentication tokens). The word "actor" is used to cover both of these cases. 17 18 Every request to Datasette has an associated actor value, available in the code as ``request.actor``. This can be ``None`` for unauthenticated requests, or a JSON compatible Python dictionary for authenticated users or API clients. 19 20 The actor dictionary can be any shape - the design of that data structure is left up to the plugins. Actors should always include a unique ``"id"`` string, as demonstrated by the "root" actor below. 21 22 Plugins can use the :ref:`plugin_hook_actor_from_request` hook to implement custom logic for authenticating an actor based on the incoming HTTP request. 23 24 .. _authentication_actor_display: 25 26 How actors are displayed 27 ------------------------ 28 29 In a number of places - such as the navigation menu and the ``/-/logout`` page - Datasette needs to display a short label representing the currently authenticated actor. 30 31 To decide what to show, Datasette looks through the following keys in the actor dictionary and uses the value of the first one that is present and not empty: 32 33 * ``display`` 34 * ``name`` 35 * ``username`` 36 * ``login`` 37 * ``id`` 38 39 If none of those keys have a value the actor dictionary is displayed as a string instead. 40 41 .. _authentication_root: 42 43 Using the "root" actor 44 ---------------------- 45 46 Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example. 47 48 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. 49 50 The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including: 51 52 * All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.) 53 * All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``create-view``, ``alter-table``, ``set-column-type``, ``drop-table``, ``drop-view``) 54 * Debug permissions (``permissions-debug``, ``debug-menu``) 55 * Any custom permissions defined by plugins 56 57 If you add explicit deny rules in ``datasette.yaml`` those can still block the 58 root actor from specific databases or tables. 59 60 The ``--root`` flag sets an internal ``root_enabled`` switch—without it, a signed-in user with ``{"id": "root"}`` is treated like any other actor. 61 62 To sign in as root, start Datasette using the ``--root`` command-line option, like this:: 63 64 datasette --root 65 66 Datasette will output a single-use-only login URL on startup:: 67 68 http://127.0.0.1:8001/-/auth-token?token=786fc524e0199d70dc9a581d851f466244e114ca92f33aa3b42a139e9388daa7 69 INFO: Started server process [25801] 70 INFO: Waiting for application startup. 71 INFO: Application startup complete. 72 INFO: Uvicorn running on http://127.0.0.1:8001 (Press CTRL+C to quit) 73 74 Click on that link and then visit ``http://127.0.0.1:8001/-/actor`` to confirm that you are authenticated as an actor that looks like this: 75 76 .. code-block:: json 77 78 { 79 "id": "root" 80 } 81 82 .. _authentication_permissions: 83 84 Permissions 85 =========== 86 87 The key question the permissions system answers is this: 88 89 Is this **actor** allowed to perform this **action**, optionally against this particular **resource**? 90 91 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. 92 93 **Actors** are :ref:`described above <authentication_actor>`. 94 95 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``. 96 97 A **resource** is the item the actor wishes to interact with - for example a specific database or table. Some actions, such as ``permissions-debug``, are not associated with a particular resource. 98 99 Datasette's built-in view actions (``view-database``, ``view-table`` etc) are allowed by Datasette's default configuration: unless you :ref:`configure additional permission rules <authentication_permissions_config>` unauthenticated users will be allowed to access content. 100 101 Other actions, including those introduced by plugins, will default to *deny*. 102 103 .. _authentication_default_deny: 104 105 Denying all permissions by default 106 ---------------------------------- 107 108 By default, Datasette allows unauthenticated access to view databases, tables, and execute SQL queries. 109 110 You may want to run Datasette in a mode where **all** access is denied by default, and you explicitly grant permissions only to authenticated users, either using the :ref:`--root mechanism <authentication_root>` or through :ref:`configuration file rules <authentication_permissions_config>` or plugins. 111 112 Use the ``--default-deny`` command-line option to run Datasette in this mode:: 113 114 datasette --default-deny data.db --root 115 116 With ``--default-deny`` enabled: 117 118 * Anonymous users are denied access to view the instance, databases, tables, and queries 119 * Authenticated users are also denied access unless they're explicitly granted permissions 120 * The root user (when using ``--root``) still has access to everything 121 * You can grant permissions using :ref:`configuration file rules <authentication_permissions_config>` or plugins 122 123 For example, to allow only a specific user to access your instance:: 124 125 datasette --default-deny data.db --config datasette.yaml 126 127 Where ``datasette.yaml`` contains: 128 129 .. code-block:: yaml 130 131 allow: 132 id: alice 133 134 This configuration will deny access to everyone except the user with ``id`` of ``alice``. 135 136 .. _authentication_permissions_explained: 137 138 How permissions are resolved 139 ---------------------------- 140 141 Permission rules describe an effect (``allow`` or ``deny``) at one of three levels: 142 143 ``resource`` 144 A specific child resource, such as the ``analytics/sales`` table. 145 146 ``parent`` 147 A parent resource, such as the ``analytics`` database. A parent rule also applies to its child resources. 148 149 ``global`` 150 Every resource for that action. 151 152 Datasette resolves matching rules from most specific to least specific: 153 154 #. Resource rules take precedence over parent and global rules. 155 #. Parent rules take precedence over global rules. 156 #. If both allow and deny rules match at the same level, deny takes precedence. 157 #. If no rule matches, access is denied. 158 159 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. 160 161 .. list-table:: Permission rule examples 162 :header-rows: 1 163 164 * - Matching rules 165 - Result 166 - Explanation 167 * - Global allow 168 - Allow 169 - The global rule is the most specific matching rule. 170 * - Global allow, parent deny 171 - Deny 172 - The parent rule is more specific. 173 * - Parent deny, resource allow 174 - Allow 175 - The resource rule is more specific. 176 * - Resource allow and resource deny 177 - Deny 178 - Deny takes precedence at the same level. 179 * - No matching rules 180 - Deny 181 - Permissions default to deny when no rule applies. 182 183 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. 184 185 Datasette performs checks using :ref:`datasette_allowed`, which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. 186 187 ``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. 188 189 When a check runs Datasette gathers allow/deny rules from multiple sources and 190 compiles them into a SQL query. The resulting query describes all of the 191 resources an actor may access for that action, together with the reasons those 192 resources were allowed or denied. The combined sources are: 193 194 * ``allow`` blocks configured in :ref:`datasette.yaml <authentication_permissions_config>`. 195 * :ref:`Actor restrictions <authentication_cli_create_token_restrict>` encoded into the actor dictionary or API token. 196 * 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. 197 * Any additional SQL provided by plugins implementing :ref:`plugin_hook_permission_resources_sql`. 198 199 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`. 200 201 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. 202 203 .. _authentication_permissions_allow: 204 205 Defining permissions with "allow" blocks 206 ---------------------------------------- 207 208 One way to define permissions in Datasette is to use an ``"allow"`` block :ref:`in the datasette.yaml file <authentication_permissions_config>`. This is a JSON document describing which actors are allowed to perform an action against a specific resource. 209 210 Each ``allow`` block is compiled into SQL and combined with any 211 :ref:`plugin-provided rules <plugin_hook_permission_resources_sql>` to produce 212 the cascading allow/deny decisions that power :ref:`datasette_allowed`. 213 214 The most basic form of allow block is this (`allow demo <https://latest.datasette.io/-/allow-debug?actor=%7B%22id%22%3A+%22root%22%7D&allow=%7B%0D%0A++++++++%22id%22%3A+%22root%22%0D%0A++++%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%22id%22%3A+%22trevor%22%7D&allow=%7B%0D%0A++++++++%22id%22%3A+%22root%22%0D%0A++++%7D>`__): 215 216 .. [[[cog 217 from metadata_doc import config_example 218 import textwrap 219 config_example(cog, textwrap.dedent( 220 """ 221 allow: 222 id: root 223 """).strip(), 224 "YAML", "JSON" 225 ) 226 .. ]]] 227 228 .. tab:: YAML 229 230 .. code-block:: yaml 231 232 allow: 233 id: root 234 235 .. tab:: JSON 236 237 .. code-block:: json 238 239 { 240 "allow": { 241 "id": "root" 242 } 243 } 244 .. [[[end]]] 245 246 This will match any actors with an ``"id"`` property of ``"root"`` - for example, an actor that looks like this: 247 248 .. code-block:: json 249 250 { 251 "id": "root", 252 "name": "Root User" 253 } 254 255 An allow block can specify "deny all" using ``false`` (`demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22root%22%0D%0A%7D&allow=false>`__): 256 257 .. [[[cog 258 from metadata_doc import config_example 259 import textwrap 260 config_example(cog, textwrap.dedent( 261 """ 262 allow: false 263 """).strip(), 264 "YAML", "JSON" 265 ) 266 .. ]]] 267 268 .. tab:: YAML 269 270 .. code-block:: yaml 271 272 allow: false 273 274 .. tab:: JSON 275 276 .. code-block:: json 277 278 { 279 "allow": false 280 } 281 .. [[[end]]] 282 283 An ``"allow"`` of ``true`` allows all access (`demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22root%22%0D%0A%7D&allow=true>`__): 284 285 .. [[[cog 286 from metadata_doc import config_example 287 import textwrap 288 config_example(cog, textwrap.dedent( 289 """ 290 allow: true 291 """).strip(), 292 "YAML", "JSON" 293 ) 294 .. ]]] 295 296 .. tab:: YAML 297 298 .. code-block:: yaml 299 300 allow: true 301 302 .. tab:: JSON 303 304 .. code-block:: json 305 306 { 307 "allow": true 308 } 309 .. [[[end]]] 310 311 Allow keys can provide a list of values. These will match any actor that has any of those values (`allow demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22cleopaws%22%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%5B%0D%0A++++++++%22simon%22%2C%0D%0A++++++++%22cleopaws%22%0D%0A++++%5D%0D%0A%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22pancakes%22%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%5B%0D%0A++++++++%22simon%22%2C%0D%0A++++++++%22cleopaws%22%0D%0A++++%5D%0D%0A%7D>`__): 312 313 .. [[[cog 314 from metadata_doc import config_example 315 import textwrap 316 config_example(cog, textwrap.dedent( 317 """ 318 allow: 319 id: 320 - simon 321 - cleopaws 322 """).strip(), 323 "YAML", "JSON" 324 ) 325 .. ]]] 326 327 .. tab:: YAML 328 329 .. code-block:: yaml 330 331 allow: 332 id: 333 - simon 334 - cleopaws 335 336 .. tab:: JSON 337 338 .. code-block:: json 339 340 { 341 "allow": { 342 "id": [ 343 "simon", 344 "cleopaws" 345 ] 346 } 347 } 348 .. [[[end]]] 349 350 This will match any actor with an ``"id"`` of either ``"simon"`` or ``"cleopaws"``. 351 352 Actors can have properties that feature a list of values. These will be matched against the list of values in an allow block. Consider the following actor: 353 354 .. code-block:: json 355 356 { 357 "id": "simon", 358 "roles": ["staff", "developer"] 359 } 360 361 This allow block will provide access to any actor that has ``"developer"`` as one of their roles (`allow demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22simon%22%2C%0D%0A++++%22roles%22%3A+%5B%0D%0A++++++++%22staff%22%2C%0D%0A++++++++%22developer%22%0D%0A++++%5D%0D%0A%7D&allow=%7B%0D%0A++++%22roles%22%3A+%5B%0D%0A++++++++%22developer%22%0D%0A++++%5D%0D%0A%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22cleopaws%22%2C%0D%0A++++%22roles%22%3A+%5B%22dog%22%5D%0D%0A%7D&allow=%7B%0D%0A++++%22roles%22%3A+%5B%0D%0A++++++++%22developer%22%0D%0A++++%5D%0D%0A%7D>`__): 362 363 .. [[[cog 364 from metadata_doc import config_example 365 import textwrap 366 config_example(cog, textwrap.dedent( 367 """ 368 allow: 369 roles: 370 - developer 371 """).strip(), 372 "YAML", "JSON" 373 ) 374 .. ]]] 375 376 .. tab:: YAML 377 378 .. code-block:: yaml 379 380 allow: 381 roles: 382 - developer 383 384 .. tab:: JSON 385 386 .. code-block:: json 387 388 { 389 "allow": { 390 "roles": [ 391 "developer" 392 ] 393 } 394 } 395 .. [[[end]]] 396 397 Note that "roles" is not a concept that is baked into Datasette - it's a convention that plugins can choose to implement and act on. 398 399 If you want to provide access to any actor with a value for a specific key, use ``"*"``. For example, to match any logged-in user specify the following (`allow demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22simon%22%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%22*%22%0D%0A%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22bot%22%3A+%22readme-bot%22%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%22*%22%0D%0A%7D>`__): 400 401 .. [[[cog 402 from metadata_doc import config_example 403 import textwrap 404 config_example(cog, textwrap.dedent( 405 """ 406 allow: 407 id: "*" 408 """).strip(), 409 "YAML", "JSON" 410 ) 411 .. ]]] 412 413 .. tab:: YAML 414 415 .. code-block:: yaml 416 417 allow: 418 id: "*" 419 420 .. tab:: JSON 421 422 .. code-block:: json 423 424 { 425 "allow": { 426 "id": "*" 427 } 428 } 429 .. [[[end]]] 430 431 You can specify that only unauthenticated actors (from anonymous HTTP requests) should be allowed access using the special ``"unauthenticated": true`` key in an allow block (`allow demo <https://latest.datasette.io/-/allow-debug?actor=null&allow=%7B%0D%0A++++%22unauthenticated%22%3A+true%0D%0A%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22hello%22%0D%0A%7D&allow=%7B%0D%0A++++%22unauthenticated%22%3A+true%0D%0A%7D>`__): 432 433 .. [[[cog 434 from metadata_doc import config_example 435 import textwrap 436 config_example(cog, textwrap.dedent( 437 """ 438 allow: 439 unauthenticated: true 440 """).strip(), 441 "YAML", "JSON" 442 ) 443 .. ]]] 444 445 .. tab:: YAML 446 447 .. code-block:: yaml 448 449 allow: 450 unauthenticated: true 451 452 .. tab:: JSON 453 454 .. code-block:: json 455 456 { 457 "allow": { 458 "unauthenticated": true 459 } 460 } 461 .. [[[end]]] 462 463 Allow keys act as an "or" mechanism. An actor will be able to execute the query if any of their JSON properties match any of the values in the corresponding lists in the ``allow`` block. The following block will allow users with either a ``role`` of ``"ops"`` OR users who have an ``id`` of ``"simon"`` or ``"cleopaws"``: 464 465 .. [[[cog 466 from metadata_doc import config_example 467 import textwrap 468 config_example(cog, textwrap.dedent( 469 """ 470 allow: 471 id: 472 - simon 473 - cleopaws 474 role: ops 475 """).strip(), 476 "YAML", "JSON" 477 ) 478 .. ]]] 479 480 .. tab:: YAML 481 482 .. code-block:: yaml 483 484 allow: 485 id: 486 - simon 487 - cleopaws 488 role: ops 489 490 .. tab:: JSON 491 492 .. code-block:: json 493 494 { 495 "allow": { 496 "id": [ 497 "simon", 498 "cleopaws" 499 ], 500 "role": "ops" 501 } 502 } 503 .. [[[end]]] 504 505 `Demo for cleopaws <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22cleopaws%22%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%5B%0D%0A++++++++%22simon%22%2C%0D%0A++++++++%22cleopaws%22%0D%0A++++%5D%2C%0D%0A++++%22role%22%3A+%22ops%22%0D%0A%7D>`__, `demo for ops role <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22trevor%22%2C%0D%0A++++%22role%22%3A+%5B%0D%0A++++++++%22ops%22%2C%0D%0A++++++++%22staff%22%0D%0A++++%5D%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%5B%0D%0A++++++++%22simon%22%2C%0D%0A++++++++%22cleopaws%22%0D%0A++++%5D%2C%0D%0A++++%22role%22%3A+%22ops%22%0D%0A%7D>`__, `demo for an actor matching neither rule <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22percy%22%2C%0D%0A++++%22role%22%3A+%5B%0D%0A++++++++%22staff%22%0D%0A++++%5D%0D%0A%7D&allow=%7B%0D%0A++++%22id%22%3A+%5B%0D%0A++++++++%22simon%22%2C%0D%0A++++++++%22cleopaws%22%0D%0A++++%5D%2C%0D%0A++++%22role%22%3A+%22ops%22%0D%0A%7D>`__. 506 507 .. _AllowDebugView: 508 509 The /-/allow-debug tool 510 ----------------------- 511 512 The ``/-/allow-debug`` tool lets you try out different ``"action"`` blocks against different ``"actor"`` JSON objects. You can try that out here: https://latest.datasette.io/-/allow-debug 513 514 .. _authentication_permissions_config: 515 516 Access permissions in ``datasette.yaml`` 517 ======================================== 518 519 There are two ways to configure permissions using ``datasette.yaml`` (or ``datasette.json``). 520 521 For simple visibility permissions you can use ``"allow"`` blocks in the root, database, table and query sections. 522 523 For other permissions you can use a ``"permissions"`` block, described :ref:`in the next section <authentication_permissions_other>`. 524 525 You can limit who is allowed to view different parts of your Datasette instance using ``"allow"`` keys in your :ref:`configuration`. 526 527 You can control the following: 528 529 * Access to the entire Datasette instance 530 * Access to specific databases 531 * Access to specific tables and views 532 * Access to specific :ref:`queries <queries>` 533 534 If a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within. 535 536 .. _authentication_permissions_instance: 537 538 Access to an instance 539 --------------------- 540 541 Here's how to restrict access to your entire Datasette instance to just the ``"id": "root"`` user: 542 543 .. [[[cog 544 from metadata_doc import config_example 545 config_example(cog, """ 546 title: My private Datasette instance 547 allow: 548 id: root 549 """) 550 .. ]]] 551 552 .. tab:: datasette.yaml 553 554 .. code-block:: yaml 555 556 557 title: My private Datasette instance 558 allow: 559 id: root 560 561 562 .. tab:: datasette.json 563 564 .. code-block:: json 565 566 { 567 "title": "My private Datasette instance", 568 "allow": { 569 "id": "root" 570 } 571 } 572 .. [[[end]]] 573 574 To deny access to all users, you can use ``"allow": false``: 575 576 .. [[[cog 577 config_example(cog, """ 578 title: My entirely inaccessible instance 579 allow: false 580 """) 581 .. ]]] 582 583 .. tab:: datasette.yaml 584 585 .. code-block:: yaml 586 587 588 title: My entirely inaccessible instance 589 allow: false 590 591 592 .. tab:: datasette.json 593 594 .. code-block:: json 595 596 { 597 "title": "My entirely inaccessible instance", 598 "allow": false 599 } 600 .. [[[end]]] 601 602 One reason to do this is if you are using a Datasette plugin - such as `datasette-permissions-sql <https://github.com/simonw/datasette-permissions-sql>`__ - to control permissions instead. 603 604 .. _authentication_permissions_database: 605 606 Access to specific databases 607 ---------------------------- 608 609 To limit access to a specific ``private.db`` database to just authenticated users, use the ``"allow"`` block like this: 610 611 .. [[[cog 612 config_example(cog, """ 613 databases: 614 private: 615 allow: 616 id: "*" 617 """) 618 .. ]]] 619 620 .. tab:: datasette.yaml 621 622 .. code-block:: yaml 623 624 625 databases: 626 private: 627 allow: 628 id: "*" 629 630 631 .. tab:: datasette.json 632 633 .. code-block:: json 634 635 { 636 "databases": { 637 "private": { 638 "allow": { 639 "id": "*" 640 } 641 } 642 } 643 } 644 .. [[[end]]] 645 646 .. _authentication_permissions_table: 647 648 Access to specific tables and views 649 ----------------------------------- 650 651 To limit access to the ``users`` table in your ``bakery.db`` database: 652 653 .. [[[cog 654 config_example(cog, """ 655 databases: 656 bakery: 657 tables: 658 users: 659 allow: 660 id: '*' 661 """) 662 .. ]]] 663 664 .. tab:: datasette.yaml 665 666 .. code-block:: yaml 667 668 669 databases: 670 bakery: 671 tables: 672 users: 673 allow: 674 id: '*' 675 676 677 .. tab:: datasette.json 678 679 .. code-block:: json 680 681 { 682 "databases": { 683 "bakery": { 684 "tables": { 685 "users": { 686 "allow": { 687 "id": "*" 688 } 689 } 690 } 691 } 692 } 693 } 694 .. [[[end]]] 695 696 This works for SQL views as well - you can list their names in the ``"tables"`` block above in the same way as regular tables. 697 698 .. warning:: 699 Restricting access to tables and views in this way will NOT prevent users from querying them using arbitrary SQL queries, `like this <https://latest.datasette.io/fixtures?sql=select+*+from+facetable>`__ for example. 700 701 If you are restricting access to specific tables you should also use the ``"allow_sql"`` block to prevent users from bypassing the limit with their own SQL queries - see :ref:`authentication_permissions_execute_sql`. 702 703 .. _authentication_permissions_query: 704 705 Access to specific queries 706 -------------------------- 707 708 :ref:`Queries <queries>` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. 709 710 To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`: 711 712 .. [[[cog 713 config_example(cog, """ 714 databases: 715 dogs: 716 queries: 717 add_name: 718 sql: INSERT INTO names (name) VALUES (:name) 719 write: true 720 allow: 721 id: 722 - root 723 """) 724 .. ]]] 725 726 .. tab:: datasette.yaml 727 728 .. code-block:: yaml 729 730 731 databases: 732 dogs: 733 queries: 734 add_name: 735 sql: INSERT INTO names (name) VALUES (:name) 736 write: true 737 allow: 738 id: 739 - root 740 741 742 .. tab:: datasette.json 743 744 .. code-block:: json 745 746 { 747 "databases": { 748 "dogs": { 749 "queries": { 750 "add_name": { 751 "sql": "INSERT INTO names (name) VALUES (:name)", 752 "write": true, 753 "allow": { 754 "id": [ 755 "root" 756 ] 757 } 758 } 759 } 760 } 761 } 762 } 763 .. [[[end]]] 764 765 .. _authentication_permissions_execute_sql: 766 767 Controlling the ability to execute arbitrary SQL 768 ------------------------------------------------ 769 770 Datasette defaults to allowing any site visitor to execute their own custom SQL queries, for example using the form on `the database page <https://latest.datasette.io/fixtures>`__ or by appending a ``?_where=`` parameter to the table page `like this <https://latest.datasette.io/fixtures/facetable?_where=_city_id=1>`__. 771 772 Access to this ability is controlled by the :ref:`actions_execute_sql` permission. 773 774 The easiest way to disable arbitrary SQL queries is using the :ref:`default_allow_sql setting <setting_default_allow_sql>` when you first start Datasette running. 775 776 You can alternatively use an ``"allow_sql"`` block to control who is allowed to execute arbitrary SQL queries. 777 778 To prevent any user from executing arbitrary SQL queries, use this: 779 780 .. [[[cog 781 config_example(cog, """ 782 allow_sql: false 783 """) 784 .. ]]] 785 786 .. tab:: datasette.yaml 787 788 .. code-block:: yaml 789 790 791 allow_sql: false 792 793 794 .. tab:: datasette.json 795 796 .. code-block:: json 797 798 { 799 "allow_sql": false 800 } 801 .. [[[end]]] 802 803 To enable just the :ref:`root user<authentication_root>` to execute SQL for all databases in your instance, use the following: 804 805 .. [[[cog 806 config_example(cog, """ 807 allow_sql: 808 id: root 809 """) 810 .. ]]] 811 812 .. tab:: datasette.yaml 813 814 .. code-block:: yaml 815 816 817 allow_sql: 818 id: root 819 820 821 .. tab:: datasette.json 822 823 .. code-block:: json 824 825 { 826 "allow_sql": { 827 "id": "root" 828 } 829 } 830 .. [[[end]]] 831 832 To limit this ability for just one specific database, use this: 833 834 .. [[[cog 835 config_example(cog, """ 836 databases: 837 mydatabase: 838 allow_sql: 839 id: root 840 """) 841 .. ]]] 842 843 .. tab:: datasette.yaml 844 845 .. code-block:: yaml 846 847 848 databases: 849 mydatabase: 850 allow_sql: 851 id: root 852 853 854 .. tab:: datasette.json 855 856 .. code-block:: json 857 858 { 859 "databases": { 860 "mydatabase": { 861 "allow_sql": { 862 "id": "root" 863 } 864 } 865 } 866 } 867 .. [[[end]]] 868 869 .. _authentication_permissions_other: 870 871 Other permissions in ``datasette.yaml`` 872 ======================================= 873 874 For all other permissions, you can use one or more ``"permissions"`` blocks in your ``datasette.yaml`` configuration file. 875 876 To grant access to the :ref:`permissions debug tool <PermissionsDebugView>` to all signed in users, you can grant ``permissions-debug`` to any actor with an ``id`` matching the wildcard ``*`` by adding this a the root of your configuration: 877 878 .. [[[cog 879 config_example(cog, """ 880 permissions: 881 debug-menu: 882 id: '*' 883 """) 884 .. ]]] 885 886 .. tab:: datasette.yaml 887 888 .. code-block:: yaml 889 890 891 permissions: 892 debug-menu: 893 id: '*' 894 895 896 .. tab:: datasette.json 897 898 .. code-block:: json 899 900 { 901 "permissions": { 902 "debug-menu": { 903 "id": "*" 904 } 905 } 906 } 907 .. [[[end]]] 908 909 To grant ``create-table`` to the user with ``id`` of ``editor`` for the ``docs`` database: 910 911 .. [[[cog 912 config_example(cog, """ 913 databases: 914 docs: 915 permissions: 916 create-table: 917 id: editor 918 """) 919 .. ]]] 920 921 .. tab:: datasette.yaml 922 923 .. code-block:: yaml 924 925 926 databases: 927 docs: 928 permissions: 929 create-table: 930 id: editor 931 932 933 .. tab:: datasette.json 934 935 .. code-block:: json 936 937 { 938 "databases": { 939 "docs": { 940 "permissions": { 941 "create-table": { 942 "id": "editor" 943 } 944 } 945 } 946 } 947 } 948 .. [[[end]]] 949 950 Other table-scoped write permissions, including ``set-column-type``, can be configured in the same place. 951 952 And for ``insert-row`` against the ``reports`` table in that ``docs`` database: 953 954 .. [[[cog 955 config_example(cog, """ 956 databases: 957 docs: 958 tables: 959 reports: 960 permissions: 961 insert-row: 962 id: editor 963 """) 964 .. ]]] 965 966 .. tab:: datasette.yaml 967 968 .. code-block:: yaml 969 970 971 databases: 972 docs: 973 tables: 974 reports: 975 permissions: 976 insert-row: 977 id: editor 978 979 980 .. tab:: datasette.json 981 982 .. code-block:: json 983 984 { 985 "databases": { 986 "docs": { 987 "tables": { 988 "reports": { 989 "permissions": { 990 "insert-row": { 991 "id": "editor" 992 } 993 } 994 } 995 } 996 } 997 } 998 } 999 .. [[[end]]] 1000 1001 The :ref:`permissions debug tool <PermissionsDebugView>` can be useful for helping test permissions that you have configured in this way. 1002 1003 .. _CreateTokenView: 1004 1005 API Tokens 1006 ========== 1007 1008 Datasette includes a default mechanism for generating API tokens that can be used to authenticate requests. 1009 1010 Authenticated users can create new API tokens using a form on the ``/-/create-token`` page. 1011 1012 Tokens created in this way can be further restricted to only allow access to specific actions, or to limit those actions to specific databases, tables or queries. 1013 1014 Created tokens can then be passed in the ``Authorization: Bearer $token`` header of HTTP requests to Datasette. 1015 1016 A token created by a user will include that user's ``"id"`` in the token payload, so any permissions granted to that user based on their ID can be made available to the token as well. 1017 1018 When one of these a token accompanies a request, the actor for that request will have the following shape: 1019 1020 .. code-block:: json 1021 1022 { 1023 "id": "user_id", 1024 "token": "dstok", 1025 "token_expires": 1667717426 1026 } 1027 1028 The ``"id"`` field duplicates the ID of the actor who first created the token. 1029 1030 The ``"token"`` field identifies that this actor was authenticated using a Datasette signed token (``dstok``). 1031 1032 The ``"token_expires"`` field, if present, indicates that the token will expire after that integer timestamp. 1033 1034 The ``/-/create-token`` page cannot be accessed by actors that are authenticated with a ``"token": "some-value"`` property. This is to prevent API tokens from being used to create more tokens. 1035 1036 Datasette plugins that implement their own form of API token authentication should follow this convention. 1037 1038 If a request presents a token that a token handler recognizes but rejects - an invalid signature, a malformed payload or an expired token - Datasette responds with a ``401`` status, the :ref:`standard JSON error format <json_api_errors>` and a ``WWW-Authenticate: Bearer error="invalid_token"`` header. This means API clients can distinguish "your token needs to be renewed" (``401``) from "your token does not grant this permission" (``403``). A ``Bearer`` token that no registered handler recognizes at all is ignored, since it may be intended for an authentication plugin. 1039 1040 You can disable the signed token feature entirely using the :ref:`allow_signed_tokens <setting_allow_signed_tokens>` setting. Requests presenting a ``dstok_`` token while the feature is disabled receive a ``401``. 1041 1042 .. _authentication_cli_create_token: 1043 1044 datasette create-token 1045 ---------------------- 1046 1047 You can also create tokens on the command line using the ``datasette create-token`` command. 1048 1049 This command takes one required argument - the ID of the actor to be associated with the created token. 1050 1051 You can specify a ``-e/--expires-after`` option in seconds. If omitted, the token will never expire. 1052 1053 The command will sign the token using the ``DATASETTE_SECRET`` environment variable, if available. You can also pass the secret using the ``--secret`` option. 1054 1055 This means you can run the command locally to create tokens for use with a deployed Datasette instance, provided you know that instance's secret. 1056 1057 To create a token for the ``root`` actor that will expire in one hour:: 1058 1059 datasette create-token root --expires-after 3600 1060 1061 To create a token that never expires using a specific secret:: 1062 1063 datasette create-token root --secret my-secret-goes-here 1064 1065 .. _authentication_cli_create_token_restrict: 1066 1067 Restricting the actions that a token can perform 1068 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 1069 1070 Tokens created using ``datasette create-token ACTOR_ID`` will inherit all of the permissions of the actor that they are associated with. 1071 1072 You can pass additional options to create tokens that are restricted to a subset of that actor's permissions. 1073 1074 To restrict the token to just specific permissions against all available databases, use the ``--all`` option:: 1075 1076 datasette create-token root --all insert-row --all update-row 1077 1078 This option can be passed as many times as you like. In the above example the token will only be allowed to insert and update rows. 1079 1080 You can also restrict permissions such that they can only be used within specific databases:: 1081 1082 datasette create-token root --database mydatabase insert-row 1083 1084 The resulting token will only be able to insert rows, and only to tables in the ``mydatabase`` database. 1085 1086 Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <queries>` - within a specific database:: 1087 1088 datasette create-token root --resource mydatabase mytable insert-row 1089 1090 These options have short versions: ``-a`` for ``--all``, ``-d`` for ``--database`` and ``-r`` for ``--resource``. 1091 1092 You can add ``--debug`` to see a JSON representation of the token that has been created. Here's a full example:: 1093 1094 datasette create-token root \ 1095 --secret mysecret \ 1096 --all view-instance \ 1097 --all view-table \ 1098 --database docs view-query \ 1099 --resource docs documents insert-row \ 1100 --resource docs documents update-row \ 1101 --debug 1102 1103 This example outputs the following:: 1104 1105 dstok_.eJxFizEKgDAMRe_y5w4qYrFXERGxDkVsMI0uxbubdjFL8l_ez1jhwEQCA6Fjjxp90qtkuHawzdjYrh8MFobLxZ_wBH0_gtnAF-hpS5VfmF8D_lnd97lHqUJgLd6sls4H1qwlhA.nH_7RecYHj5qSzvjhMU95iy0Xlc 1106 1107 Decoded: 1108 1109 { 1110 "a": "root", 1111 "token": "dstok", 1112 "t": 1670907246, 1113 "_r": { 1114 "a": [ 1115 "vi", 1116 "vt" 1117 ], 1118 "d": { 1119 "docs": [ 1120 "vq" 1121 ] 1122 }, 1123 "r": { 1124 "docs": { 1125 "documents": [ 1126 "ir", 1127 "ur" 1128 ] 1129 } 1130 } 1131 } 1132 } 1133 1134 Restrictions act as an allowlist layered on top of the actor's existing 1135 permissions. They can only remove access the actor would otherwise have—they 1136 cannot grant new access. If the underlying actor is denied by ``allow`` rules in 1137 ``datasette.yaml`` or by a plugin, a token that lists that resource in its 1138 ``"_r"`` section will still be denied. 1139 1140 To create tokens with restrictions in Python code, use the :ref:`TokenRestrictions <TokenRestrictions>` builder and pass it to :ref:`datasette.create_token() <datasette_create_token>`. 1141 1142 .. _permissions_plugins: 1143 1144 Checking permissions in plugins 1145 =============================== 1146 1147 Datasette plugins can check if an actor has permission to perform an action using :ref:`datasette_allowed`—for example:: 1148 1149 from datasette.resources import TableResource 1150 1151 can_edit = await datasette.allowed( 1152 action="update-row", 1153 resource=TableResource(database="fixtures", table="facetable"), 1154 actor=request.actor, 1155 ) 1156 1157 Use :ref:`datasette_ensure_permission` when you need to enforce a permission and 1158 raise a ``Forbidden`` error automatically. 1159 1160 Plugins that define new operations should return :class:`~datasette.permissions.Action` 1161 objects from :ref:`plugin_register_actions` and can supply additional allow/deny 1162 rules by returning :class:`~datasette.permissions.PermissionSQL` objects from the 1163 :ref:`plugin_hook_permission_resources_sql` hook. Those rules are merged with 1164 configuration ``allow`` blocks and actor restrictions to determine the final 1165 result for each check. 1166 1167 .. _authentication_actor_matches_allow: 1168 1169 actor_matches_allow() 1170 ===================== 1171 1172 Plugins that wish to implement this same ``"allow"`` block permissions scheme can take advantage of the ``datasette.utils.actor_matches_allow(actor, allow)`` function: 1173 1174 .. code-block:: python 1175 1176 from datasette.utils import actor_matches_allow 1177 1178 actor_matches_allow({"id": "root"}, {"id": "*"}) 1179 # returns True 1180 1181 The currently authenticated actor is made available to plugins as ``request.actor``. 1182 1183 .. _PermissionsDebugView: 1184 1185 Permissions debug tools 1186 ======================= 1187 1188 The debug tool at ``/-/permissions`` is available to any actor with the ``permissions-debug`` permission. By default this is just the :ref:`authenticated root user <authentication_root>` but you can open it up to all users by starting Datasette like this:: 1189 1190 datasette -s permissions.permissions-debug true data.db 1191 1192 The permission debug tools answer four different questions: 1193 1194 Why was this decision allowed or denied? 1195 Use :ref:`PermissionCheckView`. It shows every matching rule, identifies the winning specificity level, applies actor restrictions and evaluates any required actions. 1196 1197 Which resources can the current actor access? 1198 Use :ref:`AllowedResourcesView` to view an access map for a selected action. 1199 1200 Which raw rules did Datasette and its plugins contribute? 1201 Use :ref:`PermissionRulesView` to inspect the rules before they are resolved into decisions. 1202 1203 Which checks has this Datasette instance performed recently? 1204 Use ``/-/permissions`` to view recent permission activity. 1205 1206 These tools are designed to help administrators and plugin authors understand and confirm the effective permissions configuration. 1207 1208 These debug endpoints are exempt from the :ref:`JSON API stability promise <json_api_stability>` - their JSON shapes may change in future releases. 1209 1210 .. _AllowedResourcesView: 1211 1212 Allowed resources view 1213 ---------------------- 1214 1215 The ``/-/allowed`` endpoint displays resources that the current actor can access for a specified ``action``. 1216 1217 This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/allowed.json``) to get the raw JSON response instead. 1218 1219 Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair. Results are paginated: ``?_size=`` sets the page size (default 50, maximum 200, ``max`` for the maximum) and ``?_page=`` selects a page. 1220 1221 This endpoint is publicly accessible to help users understand their own permissions. The potentially sensitive ``reason`` field is only shown to users with the ``permissions-debug`` permission - it shows the plugins and explanatory reasons that were responsible for each decision. 1222 1223 .. _PermissionRulesView: 1224 1225 Permission rules view 1226 --------------------- 1227 1228 The ``/-/rules`` endpoint displays all permission rules (both allow and deny) for each candidate resource for the requested action. 1229 1230 This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/rules.json?action=view-table``) to get the raw JSON response instead. 1231 1232 Pass ``?action=`` as a query parameter to specify which action to check. The ``?_size=`` and ``?_page=`` pagination parameters work the same as on ``/-/allowed``. 1233 1234 This endpoint requires the ``permissions-debug`` permission. 1235 1236 .. _PermissionCheckView: 1237 1238 Permission check view ... output truncated, 313 more lines - call read_file again with offset=1238 to continue
Reasoning
We need to answer how auth works. The user asks "how does auth work?" We should give overview based on docs and code.
Let's explore relevant files for implementation details: actor_auth_cookie, permissions.py, app.py maybe.
Read actor_auth_cookie.
Response
Tool calls
- CodingTools_read_file:
Brcx8ynSJT8mS3gE6DxIiwMDduG11xnf
path:datasette/actor_auth_cookie.py
2026-08-10T19:19:17
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
Brcx8ynSJT8mS3gE6DxIiwMDduG11xnf1 import time 2 3 from itsdangerous import BadSignature 4 5 from datasette import hookimpl 6 from datasette.utils import baseconv 7 8 9 @hookimpl 10 def actor_from_request(datasette, request): 11 if "ds_actor" not in request.cookies: 12 return None 13 try: 14 decoded = datasette.unsign(request.cookies["ds_actor"], "actor") 15 # If it has "e" and "a" keys process the "e" expiry 16 if not isinstance(decoded, dict) or "a" not in decoded: 17 return None 18 expires_at = decoded.get("e") 19 if expires_at: 20 timestamp = int(baseconv.base62.decode(expires_at)) 21 if time.time() > timestamp: 22 return None 23 return decoded["a"] 24 except BadSignature: 25 return None
Response
Tool calls
- CodingTools_read_file:
xeLCgoNdhJ18DWbzUvjEKRl0C9JnRgDt
path:datasette/permissions.py
2026-08-10T19:19:23
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
xeLCgoNdhJ18DWbzUvjEKRl0C9JnRgDt1 import contextvars 2 from abc import ABC, abstractmethod 3 from dataclasses import dataclass 4 from typing import Any, NamedTuple 5 6 # Context variable to track when permission checks should be skipped 7 _skip_permission_checks = contextvars.ContextVar( 8 "skip_permission_checks", default=False 9 ) 10 11 # Request-scoped cache of permission check results. The ASGI router sets 12 # this to a fresh dict at the start of each request, so cached verdicts 13 # never outlive a request or leak between actors. Keys are 14 # (actor_json, action, parent, child) tuples, values are booleans. 15 _permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar( 16 "permission_check_cache", default=None 17 ) 18 19 20 class SkipPermissions: 21 """Context manager to temporarily skip permission checks. 22 23 This is not a stable API and may change in future releases. 24 25 Usage: 26 with SkipPermissions(): 27 # Permission checks are skipped within this block 28 response = await datasette.client.get("/protected") 29 """ 30 31 def __enter__(self): 32 self.token = _skip_permission_checks.set(True) 33 return self 34 35 def __exit__(self, exc_type, exc_val, exc_tb): 36 _skip_permission_checks.reset(self.token) 37 return False 38 39 40 class Resource(ABC): 41 """ 42 Base class for all resource types. 43 44 Each subclass represents a type of resource (e.g., TableResource, DatabaseResource). 45 The class itself carries metadata about the resource type. 46 Instances represent specific resources. 47 """ 48 49 # Class-level metadata (subclasses must define these) 50 name: str = None # e.g., "table", "database", "model" 51 parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables 52 53 # Instance-level optional extra attributes 54 reasons: list[str] | None = None 55 include_reasons: bool | None = None 56 57 def __init__(self, parent: str | None = None, child: str | None = None): 58 """ 59 Create a resource instance. 60 61 Args: 62 parent: The parent identifier (meaning depends on resource type) 63 child: The child identifier (meaning depends on resource type) 64 """ 65 self.parent = parent 66 self.child = child 67 self._private = None # Sentinel to track if private was set 68 69 def __str__(self) -> str: 70 return "/".join( 71 str(part) for part in (self.parent, self.child) if part is not None 72 ) 73 74 def __repr__(self) -> str: 75 return ( 76 f"{self.__class__.__name__}(parent={self.parent!r}, child={self.child!r})" 77 ) 78 79 @property 80 def private(self) -> bool: 81 """ 82 Whether this resource is private (accessible to actor but not anonymous). 83 84 This property is only available on Resource objects returned from 85 allowed_resources() when include_is_private=True is used. 86 87 Raises: 88 AttributeError: If accessed without calling include_is_private=True 89 """ 90 if self._private is None: 91 raise AttributeError( 92 "The 'private' attribute is only available when using " 93 "allowed_resources(..., include_is_private=True)" 94 ) 95 return self._private 96 97 @private.setter 98 def private(self, value: bool): 99 self._private = value 100 101 @classmethod 102 def __init_subclass__(cls): 103 """ 104 Validate resource hierarchy doesn't exceed 2 levels. 105 106 Raises: 107 ValueError: If this resource would create a 3-level hierarchy 108 """ 109 super().__init_subclass__() 110 111 if cls.parent_class is None: 112 return # Top of hierarchy, nothing to validate 113 114 # Check if our parent has a parent - that would create 3 levels 115 if cls.parent_class.parent_class is not None: 116 # We have a parent, and that parent has a parent 117 # This creates a 3-level hierarchy, which is not allowed 118 raise ValueError( 119 f"Resource {cls.__name__} creates a 3-level hierarchy: " 120 f"{cls.parent_class.parent_class.__name__} -> {cls.parent_class.__name__} -> {cls.__name__}. " 121 f"Maximum 2 levels allowed (parent -> child)." 122 ) 123 124 @classmethod 125 @abstractmethod 126 async def resources_sql(cls, datasette, actor=None) -> str: 127 """ 128 Return SQL query that returns all resources of this type. 129 130 Must return two columns: parent, child 131 """ 132 133 134 class AllowedResource(NamedTuple): 135 """A resource with the reason it was allowed (for debugging).""" 136 137 resource: Resource 138 reason: str 139 140 141 @dataclass(frozen=True, kw_only=True) 142 class Action: 143 name: str 144 description: str | None 145 abbr: str | None = None 146 resource_class: type[Resource] | None = None 147 also_requires: str | None = None # Optional action name that must also be allowed 148 149 @property 150 def takes_parent(self) -> bool: 151 """ 152 Whether this action requires a parent identifier when instantiating its resource. 153 154 Returns False for global-only actions (no resource_class). 155 Returns True for all actions with a resource_class (all resources require a parent identifier). 156 """ 157 return self.resource_class is not None 158 159 @property 160 def takes_child(self) -> bool: 161 """ 162 Whether this action requires a child identifier when instantiating its resource. 163 164 Returns False for global actions (no resource_class). 165 Returns False for parent-level resources (DatabaseResource - parent_class is None). 166 Returns True for child-level resources (TableResource, QueryResource - have a parent_class). 167 """ 168 if self.resource_class is None: 169 return False 170 return self.resource_class.parent_class is not None 171 172 173 _reason_id = 1 174 175 176 @dataclass 177 class PermissionSQL: 178 """ 179 A plugin contributes SQL that yields: 180 parent TEXT NULL, 181 child TEXT NULL, 182 allow INTEGER, -- 1 allow, 0 deny 183 reason TEXT 184 185 For restriction-only plugins, sql can be None and only restriction_sql is provided. 186 """ 187 188 sql: str | None = ( 189 None # SQL that SELECTs the 4 columns above (can be None for restriction-only) 190 ) 191 params: dict[str, Any] | None = ( 192 None # bound params for the SQL (values only; no ':' prefix) 193 ) 194 source: str | None = None # System will set this to the plugin name 195 restriction_sql: str | None = ( 196 None # Optional SQL that returns (parent, child) for restriction filtering 197 ) 198 199 @classmethod 200 def allow(cls, reason: str, _allow: bool = True) -> "PermissionSQL": 201 global _reason_id 202 i = _reason_id 203 _reason_id += 1 204 return cls( 205 sql=f"SELECT NULL AS parent, NULL AS child, {1 if _allow else 0} AS allow, :reason_{i} AS reason", 206 params={f"reason_{i}": reason}, 207 ) 208 209 @classmethod 210 def deny(cls, reason: str) -> "PermissionSQL": 211 return cls.allow(reason=reason, _allow=False) 212 213 214 # This is obsolete, replaced by Action and ResourceType 215 @dataclass 216 class Permission: 217 name: str 218 abbr: str | None 219 description: str | None 220 takes_database: bool 221 takes_resource: bool 222 default: bool 223 # This is deliberately undocumented: it's considered an internal 224 # implementation detail for view-table/view-database and should 225 # not be used by plugins as it may change in the future. 226 implies_can_view: bool = False
Response
Tool calls
- CodingTools_search_files:
PxZtyVjeaI5xComl1XNI6RBuxUZx4fea
pattern:
path:actor_from_requestdatasette
2026-08-10T19:19:43
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_search_files:
PxZtyVjeaI5xComl1XNI6RBuxUZx4feahookspecs.py:102:def actor_from_request(datasette, request): actor_auth_cookie.py:10:def actor_from_request(datasette, request): default_permissions/tokens.py:25:@hookimpl(specname="actor_from_request") app.py:2883: results = pm.hook.actor_from_request(datasette=self.ds, request=request)
Response
Tool calls
- CodingTools_read_file:
8tuydaN8PhHCvGGZlyMT2L4XZLilR2vn
path:datasette/default_permissions/tokens.py
2026-08-10T19:19:47
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
8tuydaN8PhHCvGGZlyMT2L4XZLilR2vn1 """ 2 Token authentication for Datasette. 3 4 Registers the default SignedTokenHandler and delegates token verification 5 to datasette.verify_token() so all registered handlers are tried. 6 """ 7 8 from __future__ import annotations 9 10 from typing import TYPE_CHECKING 11 12 if TYPE_CHECKING: 13 from datasette.app import Datasette 14 15 from datasette import hookimpl 16 from datasette.tokens import SignedTokenHandler 17 18 19 @hookimpl 20 def register_token_handler(datasette: Datasette): 21 """Register the default signed token handler.""" 22 return SignedTokenHandler() 23 24 25 @hookimpl(specname="actor_from_request") 26 async def actor_from_signed_api_token(datasette: Datasette, request) -> dict | None: 27 """ 28 Authenticate requests using API tokens by delegating to all registered 29 token handlers via datasette.verify_token(). 30 """ 31 authorization = request.headers.get("authorization") 32 if not authorization: 33 return None 34 if not authorization.startswith("Bearer "): 35 return None 36 37 token = authorization[len("Bearer ") :] 38 return await datasette.verify_token(token)
Response
Tool calls
- CodingTools_read_file:
rcLYqUexscRXOhLvYJlkDzp5iEfx1Sb9
path:datasette/tokens.py
2026-08-10T19:19:53
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
rcLYqUexscRXOhLvYJlkDzp5iEfx1Sb91 """ 2 Token handler system for Datasette. 3 4 Provides a base class for token handlers and the default signed token handler. 5 Plugins can implement register_token_handler to provide custom token backends 6 (e.g. database-backed tokens that can be revoked and audited). 7 """ 8 9 from __future__ import annotations 10 11 import dataclasses 12 import time 13 from typing import TYPE_CHECKING 14 15 import itsdangerous 16 17 if TYPE_CHECKING: 18 from datasette.app import Datasette 19 20 21 class TokenInvalid(Exception): 22 """ 23 Raised by a TokenHandler when a token it recognizes is invalid - 24 for example a bad signature, malformed payload or expired token. 25 26 Datasette responds to this with an HTTP 401 error. Handlers should 27 return None instead for tokens they do not recognize at all, so that 28 other registered handlers get a chance to verify them. 29 """ 30 31 def __init__(self, message="Invalid token"): 32 self.message = message 33 super().__init__(message) 34 35 36 @dataclasses.dataclass 37 class TokenRestrictions: 38 """ 39 Restrictions to apply to a token, limiting which actions it can perform. 40 41 Use the builder methods to construct restrictions:: 42 43 restrictions = (TokenRestrictions() 44 .allow_all("view-instance") 45 .allow_database("mydb", "create-table") 46 .allow_resource("mydb", "mytable", "insert-row")) 47 """ 48 49 all: list[str] = dataclasses.field(default_factory=list) 50 database: dict[str, list[str]] = dataclasses.field(default_factory=dict) 51 resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict) 52 53 def allow_all(self, action: str) -> TokenRestrictions: 54 """Allow an action across all databases and resources.""" 55 self.all.append(action) 56 return self 57 58 def allow_database(self, database: str, action: str) -> TokenRestrictions: 59 """Allow an action on a specific database.""" 60 self.database.setdefault(database, []).append(action) 61 return self 62 63 def allow_resource( 64 self, database: str, resource: str, action: str 65 ) -> TokenRestrictions: 66 """Allow an action on a specific resource within a database.""" 67 self.resource.setdefault(database, {}).setdefault(resource, []).append(action) 68 return self 69 70 def abbreviated(self, datasette: Datasette) -> dict | None: 71 """ 72 Return the abbreviated ``_r`` dictionary shape for this set of 73 restrictions, using action abbreviations registered with ``datasette``. 74 Returns ``None`` if no restrictions are set. 75 """ 76 if not (self.all or self.database or self.resource): 77 return None 78 79 def abbreviate_action(action): 80 action_obj = datasette.actions.get(action) 81 if not action_obj: 82 return action 83 return action_obj.abbr or action 84 85 result: dict = {} 86 if self.all: 87 result["a"] = [abbreviate_action(a) for a in self.all] 88 if self.database: 89 result["d"] = { 90 database: [abbreviate_action(a) for a in actions] 91 for database, actions in self.database.items() 92 } 93 if self.resource: 94 result["r"] = {} 95 for database, resources in self.resource.items(): 96 for resource, actions in resources.items(): 97 result["r"].setdefault(database, {})[resource] = [ 98 abbreviate_action(a) for a in actions 99 ] 100 return result 101 102 103 class TokenHandler: 104 """ 105 Base class for token handlers. 106 107 Subclass this and implement create_token() and verify_token() to provide 108 a custom token backend. Return an instance from the register_token_handler hook. 109 """ 110 111 name: str = "" 112 113 async def create_token( 114 self, 115 datasette: Datasette, 116 actor_id: str, 117 *, 118 expires_after: int | None = None, 119 restrictions: TokenRestrictions | None = None, 120 ) -> str: 121 """Create and return a token string for the given actor.""" 122 raise NotImplementedError 123 124 async def verify_token(self, datasette: Datasette, token: str) -> dict | None: 125 """ 126 Verify a token and return an actor dict. 127 128 Return None if this handler does not recognize the token at all, 129 so other handlers can try it. Raise TokenInvalid if the token is 130 recognized but invalid (bad signature, malformed, expired) - the 131 request will fail with a 401 error. 132 """ 133 raise NotImplementedError 134 135 136 class SignedTokenHandler(TokenHandler): 137 """ 138 Default token handler using itsdangerous signed tokens (dstok_ prefix). 139 """ 140 141 name = "signed" 142 143 async def create_token( 144 self, 145 datasette: Datasette, 146 actor_id: str, 147 *, 148 expires_after: int | None = None, 149 restrictions: TokenRestrictions | None = None, 150 ) -> str: 151 if not datasette.setting("allow_signed_tokens"): 152 raise ValueError( 153 "Signed tokens are not enabled for this Datasette instance" 154 ) 155 156 token = {"a": actor_id, "t": int(time.time())} 157 158 if expires_after: 159 token["d"] = expires_after 160 if restrictions is not None: 161 abbreviated = restrictions.abbreviated(datasette) 162 if abbreviated is not None: 163 token["_r"] = abbreviated 164 return "dstok_{}".format(datasette.sign(token, namespace="token")) 165 166 async def verify_token(self, datasette: Datasette, token: str) -> dict | None: 167 prefix = "dstok_" 168 169 if not token.startswith(prefix): 170 # Not one of our tokens - leave it for other handlers 171 return None 172 173 if not datasette.setting("allow_signed_tokens"): 174 raise TokenInvalid( 175 "Signed tokens are not enabled for this Datasette instance" 176 ) 177 178 max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl") 179 180 raw = token[len(prefix) :] 181 try: 182 decoded = datasette.unsign(raw, namespace="token") 183 except itsdangerous.BadSignature: 184 raise TokenInvalid("Invalid token signature") 185 186 if "t" not in decoded: 187 raise TokenInvalid("Invalid token: no timestamp") 188 created = decoded["t"] 189 if not isinstance(created, int): 190 raise TokenInvalid("Invalid token: invalid timestamp") 191 192 duration = decoded.get("d") 193 if duration is not None and not isinstance(duration, int): 194 raise TokenInvalid("Invalid token: invalid duration") 195 196 if (duration is None and max_signed_tokens_ttl) or ( 197 duration is not None 198 and max_signed_tokens_ttl 199 and duration > max_signed_tokens_ttl 200 ): 201 duration = max_signed_tokens_ttl 202 203 if duration and time.time() - created > duration: 204 raise TokenInvalid("Token has expired") 205 206 actor = {"id": decoded["a"], "token": "dstok"} 207 208 if "_r" in decoded: 209 actor["_r"] = decoded["_r"] 210 211 if duration: 212 actor["token_expires"] = created + duration 213 214 return actor
Response
Tool calls
- CodingTools_read_file:
Aosw5X55tNjKUJLloiHmeW1Hwhlewhsj
path:
offset:datasette/app.py2870limit:100
2026-08-10T19:20:13
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
Aosw5X55tNjKUJLloiHmeW1Hwhlewhsj2871 scope_modifications = {} 2872 # Apply force_https_urls, if set 2873 if ( 2874 self.ds.setting("force_https_urls") 2875 and scope["type"] == "http" 2876 and scope.get("scheme") != "https" 2877 ): 2878 scope_modifications["scheme"] = "https" 2879 # Handle authentication 2880 default_actor = scope.get("actor") or None 2881 actor = None 2882 token_error = None 2883 results = pm.hook.actor_from_request(datasette=self.ds, request=request) 2884 for result in results: 2885 try: 2886 result = await await_me_maybe(result) 2887 except TokenInvalid as ex: 2888 # A presented token was recognized but rejected - fail the 2889 # request with a 401 even if another credential is valid, 2890 # but keep awaiting the remaining coroutines first 2891 if token_error is None: 2892 token_error = ex 2893 continue 2894 if result and actor is None: 2895 actor = result 2896 # Don't break — we must await all coroutines to avoid 2897 # "coroutine was never awaited" warnings 2898 if token_error is not None: 2899 return await self.handle_401(request, send, token_error) 2900 scope_modifications["actor"] = actor or default_actor 2901 scope = dict(scope, **scope_modifications) 2902 2903 match, view = resolve_routes(self.routes, path) 2904 2905 if match is None: 2906 return await self.handle_404(request, send) 2907 2908 new_scope = dict(scope, url_route={"kwargs": match.groupdict()}) 2909 request.scope = new_scope 2910 try: 2911 response = await view(request, send) 2912 if response: 2913 self.ds._write_messages_to_response(request, response) 2914 await response.asgi_send(send) 2915 return 2916 except NotFound as exception: 2917 return await self.handle_404(request, send, exception) 2918 except Forbidden as exception: 2919 # Try the forbidden() plugin hook 2920 for custom_response in pm.hook.forbidden( 2921 datasette=self.ds, request=request, message=exception.args[0] 2922 ): 2923 custom_response = await await_me_maybe(custom_response) 2924 assert ( 2925 custom_response 2926 ), "Default forbidden() hook should have been called" 2927 return await custom_response.asgi_send(send) 2928 except Exception as exception: # noqa: BLE001 2929 # This IS the top-level error handler - it must catch everything 2930 return await self.handle_exception(request, send, exception) 2931 2932 async def handle_401(self, request, send, exception): 2933 # A presented bearer token was recognized by a handler but rejected. 2934 # Bearer tokens are API credentials, so this is always JSON. 2935 headers = {"www-authenticate": 'Bearer error="invalid_token"'} 2936 if self.ds.cors: 2937 add_cors_headers(headers) 2938 response = Response.error([str(exception)], 401, headers=headers) 2939 await response.asgi_send(send) 2940 2941 async def handle_404(self, request, send, exception=None): 2942 # If path contains % encoding, redirect to tilde encoding 2943 if "%" in request.path: 2944 # Try the same path but with "%" replaced by "~" 2945 # and "~" replaced with "~7E" 2946 # and "." replaced with "~2E" 2947 new_path = ( 2948 request.path.replace("~", "~7E").replace("%", "~").replace(".", "~2E") 2949 ) 2950 if request.query_string: 2951 new_path += f"?{request.query_string}" 2952 await asgi_send_redirect(send, new_path) 2953 return 2954 # If URL has a trailing slash, redirect to URL without it 2955 path = request.scope.get( 2956 "raw_path", request.scope["path"].encode("utf8") 2957 ).partition(b"?")[0] 2958 context = {} 2959 if path.endswith(b"/"): 2960 path = path.rstrip(b"/") 2961 if request.scope["query_string"]: 2962 path += b"?" + request.scope["query_string"] 2963 await asgi_send_redirect(send, path.decode("latin1")) 2964 else: 2965 # Is there a pages/* template matching this path? 2966 route_path = request.scope.get("route_path", request.scope["path"]) 2967 # Jinja requires template names to use "/" even on Windows 2968 template_name = "pages" + route_path + ".html" 2969 # Build a list of pages/blah/{name}.html matching expressions 2970 environment = self.ds.get_jinja_environment(request) ... 328 more lines, use offset=2970 to continue
Response
Tool calls
- CodingTools_read_file:
uLw9omhq3LdIACnlot3faUOYp7OlP6Dc
path:datasette/hookspecs.py
2026-08-10T19:20:25
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
uLw9omhq3LdIACnlot3faUOYp7OlP6Dc1 from pluggy import HookimplMarker, HookspecMarker 2 3 hookspec = HookspecMarker("datasette") 4 hookimpl = HookimplMarker("datasette") 5 6 7 @hookspec 8 def startup(datasette): 9 """Fires directly after Datasette first starts running""" 10 11 12 @hookspec 13 def asgi_wrapper(datasette): 14 """Returns an ASGI middleware callable to wrap our ASGI application with""" 15 16 17 @hookspec 18 def prepare_connection(conn, database, datasette): 19 """Modify SQLite connection in some way e.g. register custom SQL functions""" 20 21 22 @hookspec 23 def prepare_jinja2_environment(env, datasette): 24 """Modify Jinja2 template environment e.g. register custom template tags""" 25 26 27 @hookspec 28 def extra_css_urls(template, database, table, columns, view_name, request, datasette): 29 """Extra CSS URLs added by this plugin""" 30 31 32 @hookspec 33 def extra_js_urls(template, database, table, columns, view_name, request, datasette): 34 """Extra JavaScript URLs added by this plugin""" 35 36 37 @hookspec 38 def extra_body_script( 39 template, database, table, columns, view_name, request, datasette 40 ): 41 """Extra JavaScript code to be included in <script> at bottom of body""" 42 43 44 @hookspec 45 def extra_template_vars( 46 template, database, table, columns, view_name, request, datasette 47 ): 48 """Extra template variables to be made available to the template - can return dict or callable or awaitable""" 49 50 51 @hookspec 52 def publish_subcommand(publish): 53 """Subcommands for 'datasette publish'""" 54 55 56 @hookspec 57 def render_cell( 58 row, 59 value, 60 column, 61 table, 62 pks, 63 database, 64 datasette, 65 request, 66 column_type, 67 ): 68 """Customize rendering of HTML table cell values""" 69 70 71 @hookspec 72 def register_output_renderer(datasette): 73 """Register a renderer to output data in a different format""" 74 75 76 @hookspec 77 def register_facet_classes(): 78 """Register Facet subclasses""" 79 80 81 @hookspec 82 def register_actions(datasette): 83 """Register actions: returns a list of datasette.permission.Action objects""" 84 85 86 @hookspec 87 def register_column_types(datasette): 88 """Return a list of ColumnType subclasses""" 89 90 91 @hookspec 92 def register_routes(datasette): 93 """Register URL routes: return a list of (regex, view_function) pairs""" 94 95 96 @hookspec 97 def register_commands(cli): 98 """Register additional CLI commands, e.g. 'datasette mycommand ...'""" 99 100 101 @hookspec 102 def actor_from_request(datasette, request): 103 """Return an actor dictionary based on the incoming request""" 104 105 106 @hookspec(firstresult=True) 107 def actors_from_ids(datasette, actor_ids): 108 """Returns a dictionary mapping those IDs to actor dictionaries""" 109 110 111 @hookspec 112 def jinja2_environment_from_request(datasette, request, env): 113 """Return a Jinja2 environment based on the incoming request""" 114 115 116 @hookspec 117 def filters_from_request(request, database, table, datasette): 118 """ 119 Return datasette.filters.FilterArguments( 120 where_clauses=[str, str, str], 121 params={}, 122 human_descriptions=[str, str, str], 123 extra_context={} 124 ) based on the request""" 125 126 127 @hookspec 128 def permission_resources_sql(datasette, actor, action): 129 """Return SQL query fragments for permission checks on resources. 130 131 Returns None, a PermissionSQL object, or a list of PermissionSQL objects. 132 Each PermissionSQL contains SQL that should return rows with columns: 133 parent (str|None), child (str|None), allow (int), reason (str). 134 135 Used to efficiently check permissions across multiple resources at once. 136 """ 137 138 139 @hookspec 140 def register_magic_parameters(datasette): 141 """Return a list of (name, function) magic parameter functions""" 142 143 144 @hookspec 145 def forbidden(datasette, request, message): 146 """Custom response for a 403 forbidden error""" 147 148 149 @hookspec 150 def menu_links(datasette, actor, request): 151 """Links for the navigation menu""" 152 153 154 @hookspec 155 def jump_items_sql(datasette, actor, request): 156 """SQL fragments for extra items in the jump menu""" 157 158 159 @hookspec 160 def row_actions(datasette, actor, request, database, table, row): 161 """Items for the row actions menu""" 162 163 164 @hookspec 165 def table_actions(datasette, actor, database, table, request): 166 """Items for the table actions menu""" 167 168 169 @hookspec 170 def view_actions(datasette, actor, database, view, request): 171 """Items for the view actions menu""" 172 173 174 @hookspec 175 def query_actions(datasette, actor, database, query_name, request, sql, params): 176 """Items for the query and stored query actions menu""" 177 178 179 @hookspec 180 def database_actions(datasette, actor, database, request): 181 """Items for the database actions menu""" 182 183 184 @hookspec 185 def homepage_actions(datasette, actor, request): 186 """Items for the homepage actions menu""" 187 188 189 @hookspec 190 def handle_exception(datasette, request, exception): 191 """Handle an uncaught exception. Can return a Response or None.""" 192 193 194 @hookspec 195 def track_event(datasette, event): 196 """Respond to an event tracked by Datasette""" 197 198 199 @hookspec 200 def register_events(datasette): 201 """Return a list of Event subclasses to use with track_event()""" 202 203 204 @hookspec 205 def top_homepage(datasette, request): 206 """HTML to include at the top of the homepage""" 207 208 209 @hookspec 210 def top_database(datasette, request, database): 211 """HTML to include at the top of the database page""" 212 213 214 @hookspec 215 def top_table(datasette, request, database, table): 216 """HTML to include at the top of the table page""" 217 218 219 @hookspec 220 def top_row(datasette, request, database, table, row): 221 """HTML to include at the top of the row page""" 222 223 224 @hookspec 225 def top_query(datasette, request, database, sql): 226 """HTML to include at the top of the query results page""" 227 228 229 @hookspec 230 def top_stored_query(datasette, request, database, query_name): 231 """HTML to include at the top of the stored query page""" 232 233 234 @hookspec 235 def register_token_handler(datasette): 236 """Return a TokenHandler instance for token creation and verification""" 237 238 239 @hookspec 240 def write_wrapper(datasette, database, request, transaction): 241 """Called when a write function is about to execute. 242 243 Return a generator function that accepts a ``conn`` argument and 244 optionally a ``track_event`` argument. The generator should 245 ``yield`` exactly once: code before the ``yield`` runs before 246 the write, code after the ``yield`` runs after the write 247 completes. The result of the write is sent back through the 248 ``yield``, so you can capture it with ``result = yield``. 249 250 If your generator accepts ``track_event``, you can call 251 ``track_event(event)`` to queue an event that will be dispatched 252 via ``datasette.track_event()`` after the write commits 253 successfully. Events are discarded if the write raises an 254 exception. 255 256 If the write raises an exception, it is thrown into the generator 257 so you can handle it with a try/except around the ``yield``. 258 259 ``request`` may be ``None`` for writes not originating from an 260 HTTP request. ``transaction`` is ``True`` if the write will 261 be wrapped in a transaction. 262 263 Return ``None`` to skip wrapping. 264 """
Response
Tool calls
- CodingTools_read_file:
t42sYN9OcC0uIKXVuw2AmfbSaOfcWFK0
path:
limit:tests/test_auth.py200
2026-08-10T19:20:47
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
t42sYN9OcC0uIKXVuw2AmfbSaOfcWFK01 import time 2 3 import pytest 4 from bs4 import BeautifulSoup as Soup 5 from click.testing import CliRunner 6 7 from datasette.cli import cli 8 from datasette.resources import ( 9 DatabaseResource, 10 TableResource, 11 ) 12 from datasette.utils import baseconv 13 14 from .utils import cookie_was_deleted, last_event 15 16 17 @pytest.mark.asyncio 18 async def test_auth_token(ds_client): 19 """The /-/auth-token endpoint sets the correct cookie""" 20 assert ds_client.ds._root_token is not None 21 path = f"/-/auth-token?token={ds_client.ds._root_token}" 22 response = await ds_client.get(path) 23 assert response.status_code == 302 24 assert "/" == response.headers["Location"] 25 assert {"a": {"id": "root"}} == ds_client.ds.unsign( 26 response.cookies["ds_actor"], "actor" 27 ) 28 # Should have recorded a login event 29 event = last_event(ds_client.ds) 30 assert event.name == "login" 31 assert event.actor == {"id": "root"} 32 # Check that a second with same token fails 33 assert ds_client.ds._root_token is None 34 assert (await ds_client.get(path)).status_code == 403 35 # But attempting with same token while logged in as root should redirect to / 36 response = await ds_client.get( 37 path, cookies={"ds_actor": ds_client.actor_cookie({"id": "root"})} 38 ) 39 assert response.status_code == 302 40 assert response.headers["Location"] == "/" 41 42 43 @pytest.mark.asyncio 44 async def test_actor_cookie(ds_client): 45 """A valid actor cookie sets request.scope['actor']""" 46 cookie = ds_client.actor_cookie({"id": "test"}) 47 await ds_client.get("/", cookies={"ds_actor": cookie}) 48 assert ds_client.ds._last_request.scope["actor"] == {"id": "test"} 49 50 51 @pytest.mark.asyncio 52 async def test_actor_cookie_invalid(ds_client): 53 cookie = ds_client.actor_cookie({"id": "test"}) 54 # Break the signature 55 await ds_client.get("/", cookies={"ds_actor": cookie[:-1] + "."}) 56 assert ds_client.ds._last_request.scope["actor"] is None 57 # Break the cookie format 58 cookie = ds_client.ds.sign({"b": {"id": "test"}}, "actor") 59 await ds_client.get("/", cookies={"ds_actor": cookie}) 60 assert ds_client.ds._last_request.scope["actor"] is None 61 62 63 @pytest.mark.asyncio 64 @pytest.mark.parametrize( 65 "offset,expected", 66 [ 67 ((24 * 60 * 60), {"id": "test"}), 68 (-(24 * 60 * 60), None), 69 ], 70 ) 71 async def test_actor_cookie_that_expires(ds_client, offset, expected): 72 expires_at = int(time.time()) + offset 73 cookie = ds_client.ds.sign( 74 {"a": {"id": "test"}, "e": baseconv.base62.encode(expires_at)}, "actor" 75 ) 76 await ds_client.get("/", cookies={"ds_actor": cookie}) 77 assert ds_client.ds._last_request.scope["actor"] == expected 78 79 80 def test_logout(app_client): 81 # Keeping app_client for the moment because of csrftoken_from 82 response = app_client.get( 83 "/-/logout", cookies={"ds_actor": app_client.actor_cookie({"id": "test"})} 84 ) 85 assert 200 == response.status 86 assert "<p>You are logged in as <strong>test</strong></p>" in response.text 87 # Actors without an id get full serialization 88 response2 = app_client.get( 89 "/-/logout", cookies={"ds_actor": app_client.actor_cookie({"name2": "bob"})} 90 ) 91 assert 200 == response2.status 92 assert ( 93 "<p>You are logged in as <strong>{'name2': 'bob'}</strong></p>" 94 in response2.text 95 ) 96 # If logged out you get a redirect to / 97 response3 = app_client.get("/-/logout") 98 assert 302 == response3.status 99 # A POST to that page should log the user out 100 response4 = app_client.post( 101 "/-/logout", 102 csrftoken_from=True, 103 cookies={"ds_actor": app_client.actor_cookie({"id": "test"})}, 104 ) 105 # Should have recorded a logout event 106 event = last_event(app_client.ds) 107 assert event.name == "logout" 108 assert event.actor == {"id": "test"} 109 # The ds_actor cookie should have been unset 110 assert cookie_was_deleted(response4, "ds_actor") 111 # Should also have set a message 112 messages = app_client.ds.unsign(response4.cookies["ds_messages"], "messages") 113 assert [["You are now logged out", 2]] == messages 114 115 116 @pytest.mark.asyncio 117 @pytest.mark.parametrize("path", ["/", "/fixtures", "/fixtures/facetable"]) 118 async def test_logout_button_in_navigation(ds_client, path): 119 response = await ds_client.get( 120 path, cookies={"ds_actor": ds_client.actor_cookie({"id": "test"})} 121 ) 122 anon_response = await ds_client.get(path) 123 for fragment in ( 124 "<strong>test</strong>", 125 '<form class="nav-menu-logout" action="/-/logout" method="post">', 126 ): 127 assert fragment in response.text 128 assert fragment not in anon_response.text 129 130 131 @pytest.mark.asyncio 132 @pytest.mark.parametrize("path", ["/", "/fixtures", "/fixtures/facetable"]) 133 async def test_no_logout_button_in_navigation_if_no_ds_actor_cookie(ds_client, path): 134 response = await ds_client.get(path + "?_bot=1") 135 assert "<strong>bot</strong>" in response.text 136 assert ( 137 '<form class="nav-menu-logout" action="/-/logout" method="post">' 138 not in response.text 139 ) 140 141 142 @pytest.mark.parametrize( 143 "post_data,errors,expected_duration,expected_r", 144 ( 145 ({"expire_type": ""}, [], None, None), 146 ({"expire_type": "x"}, ["Invalid expire duration"], None, None), 147 ({"expire_type": "minutes"}, ["Invalid expire duration"], None, None), 148 ( 149 {"expire_type": "minutes", "expire_duration": "x"}, 150 ["Invalid expire duration"], 151 None, 152 None, 153 ), 154 ( 155 {"expire_type": "minutes", "expire_duration": "-1"}, 156 ["Invalid expire duration"], 157 None, 158 None, 159 ), 160 ( 161 {"expire_type": "minutes", "expire_duration": "0"}, 162 ["Invalid expire duration"], 163 None, 164 None, 165 ), 166 ({"expire_type": "minutes", "expire_duration": "10"}, [], 600, None), 167 ({"expire_type": "hours", "expire_duration": "10"}, [], 10 * 60 * 60, None), 168 ({"expire_type": "days", "expire_duration": "3"}, [], 60 * 60 * 24 * 3, None), 169 # Token restrictions 170 ({"all:view-instance": "on"}, [], None, {"a": ["vi"]}), 171 ({"database:fixtures:view-query": "on"}, [], None, {"d": {"fixtures": ["vq"]}}), 172 ( 173 {"resource:fixtures:facetable:insert-row": "on"}, 174 [], 175 None, 176 {"r": {"fixtures": {"facetable": ["ir"]}}}, 177 ), 178 ), 179 ) 180 def test_auth_create_token( 181 app_client, post_data, errors, expected_duration, expected_r 182 ): 183 assert app_client.get("/-/create-token").status == 403 184 ds_actor = app_client.actor_cookie({"id": "test"}) 185 response = app_client.get("/-/create-token", cookies={"ds_actor": ds_actor}) 186 assert response.status == 200 187 assert ">Create an API token<" in response.text 188 # Confirm some aspects of expected set of checkboxes 189 soup = Soup(response.text, "html.parser") 190 checkbox_names = {el["name"] for el in soup.select('input[type="checkbox"]')} 191 assert checkbox_names.issuperset( 192 { 193 "all:view-instance", 194 "all:view-query", 195 "database:fixtures:drop-table", 196 "resource:fixtures:foreign_key_references:insert-row", 197 "resource:fixtures:facetable:set-column-type", 198 } 199 ) 200 # Now try actually creating one ... 326 more lines, use offset=200 to continue
Reasoning
We need provide overview of auth work. Let's search for how actor is determined and permissions flow.
Read app.py around actor handling.
Response
Tool calls
- CodingTools_search_files:
JanUSGis98tTqmoUpoOmooFEEVSZCr6p
pattern:
path:request.actor
max_results:datasette50
2026-08-10T19:21:11
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_search_files:
JanUSGis98tTqmoUpoOmooFEEVSZCr6pfilters.py:22: actor=request.actor, default_magic_parameters.py:15: if request.actor is None: default_magic_parameters.py:17: return request.actor[key] facets.py:259: self.request.actor, self.database, self.table, column, values views/special.py:58: await self.ds.ensure_permission(action=self.permission, actor=request.actor) views/special.py:91: await datasette.ensure_permission(action="view-instance", actor=request.actor) views/special.py:123: request.actor, views/special.py:156: await self.ds.ensure_permission(action="view-instance", actor=request.actor) views/special.py:180: actor=request.actor, views/special.py:209: if request.actor and request.actor.get("id") == "root": views/special.py:230: if not request.actor: views/special.py:235: {"actor": request.actor}, views/special.py:242: await self.ds.track_event(LogoutEvent(actor=request.actor)) views/special.py:251: await self.ds.ensure_permission(action="view-instance", actor=request.actor) views/special.py:252: await self.ds.ensure_permission(action="permissions-debug", actor=request.actor) views/special.py:259: if (check.actor or {}).get("id") != request.actor["id"] views/special.py:265: if (check.actor or {}).get("id") == request.actor["id"] views/special.py:289: await self.ds.ensure_permission(action="view-instance", actor=request.actor) views/special.py:290: await self.ds.ensure_permission(action="permissions-debug", actor=request.actor) views/special.py:318: action="permissions-debug", actor=request.actor views/special.py:366: actor = request.actor if isinstance(request.actor, dict) else None views/special.py:487: await self.ds.ensure_permission(action="view-instance", actor=request.actor) views/special.py:488: await self.ds.ensure_permission(action="permissions-debug", actor=request.actor) views/special.py:511: actor = request.actor if isinstance(request.actor, dict) else None views/special.py:670: await self.ds.ensure_permission(action="permissions-debug", actor=request.actor) views/special.py:692: or json.dumps(request.actor, indent=2), views/special.py:704: actor = request.actor views/special.py:752: action="permissions-debug", actor=request.actor views/special.py:763: await self.ds.ensure_permission(action="view-instance", actor=request.actor) views/special.py:767: await self.ds.ensure_permission(action="view-instance", actor=request.actor) views/special.py:789: if not request.actor: views/special.py:791: if not request.actor.get("id"): views/special.py:795: if request.actor.get("token"): views/special.py:803: db_page = await self.ds.allowed_resources("view-database", request.actor) views/special.py:806: table_page = await self.ds.allowed_resources("view-table", request.actor) views/special.py:835: "actor": request.actor, views/special.py:894: request.actor["id"], views/special.py:902: actor=request.actor, views/special.py:922: request.actor, views/special.py:932: request.actor, views/special.py:954: actor=request.actor, views/special.py:1001: actor=request.actor, views/special.py:1016: actor=request.actor, views/special.py:1049: request.actor, views/special.py:1090: actor=request.actor, views/special.py:1326: request.actor, views/special.py:1368: actor=request.actor, views/special.py:1404: actor=request.actor, views/row.py:402: request.actor, views/row.py:446: request.actor, database, table, column, values ... results truncated at 50 matches
Reasoning
We need summarize how auth works. Use docs and code.
Let's read permissions more for allowed function.
Response
Tool calls
- CodingTools_search_files:
SA6CsXgy2FGFJOnqXzOdCdhyo4vVMq1l
pattern:
path:def alloweddatasette
2026-08-10T19:21:24
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_search_files:
SA6CsXgy2FGFJOnqXzOdCdhyo4vVMq1lapp.py:1695: async def allowed_resources_sql( app.py:1735: async def allowed_resources( app.py:1881: async def allowed( app.py:1912: async def allowed_many(
Response
Tool calls
- CodingTools_read_file:
6u72T11sRj9oBG4UbFAJvq230WZUN2X5
path:
offset:datasette/app.py1880limit:80
2026-08-10T19:21:29
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
6u72T11sRj9oBG4UbFAJvq230WZUN2X51881 async def allowed( 1882 self, 1883 *, 1884 action: str, 1885 resource: Resource = None, 1886 actor: dict | None = None, 1887 ) -> bool: 1888 """ 1889 Check if actor can perform action on specific resource. 1890 1891 Uses SQL to check permission for a single resource without fetching all resources. 1892 This is efficient - it does NOT call allowed_resources() and check membership. 1893 1894 For global actions, resource should be None (or omitted). 1895 1896 Example: 1897 from datasette.resources import TableResource 1898 can_view = await datasette.allowed( 1899 action="view-table", 1900 resource=TableResource(database="analytics", table="users"), 1901 actor=actor 1902 ) 1903 1904 # For global actions, resource can be omitted: 1905 can_debug = await datasette.allowed(action="permissions-debug", actor=actor) 1906 """ 1907 results = await self.allowed_many( 1908 actions=[action], resource=resource, actor=actor 1909 ) 1910 return results[action] 1911 1912 async def allowed_many( 1913 self, 1914 *, 1915 actions: Sequence[str], 1916 resource: Resource = None, 1917 actor: dict | None = None, 1918 ) -> dict[str, bool]: 1919 """ 1920 Check several actions against one resource for one actor. 1921 1922 Resolves every action (plus any also_requires dependencies) with a 1923 single internal database query, instead of one or two queries per 1924 action. Results are stored in the request-scoped permission cache, 1925 so subsequent datasette.allowed() calls for the same checks within 1926 the same request are served from the cache. 1927 1928 Example: 1929 from datasette.resources import TableResource 1930 results = await datasette.allowed_many( 1931 actions=["edit-schema", "drop-table", "insert-row"], 1932 resource=TableResource(database="data", table="exercise"), 1933 actor=actor, 1934 ) 1935 # {"edit-schema": True, "drop-table": True, "insert-row": False} 1936 """ 1937 from datasette.permissions import ( 1938 _permission_check_cache, 1939 _skip_permission_checks, 1940 ) 1941 from datasette.utils.actions_sql import check_permissions_for_actions 1942 1943 # For global actions, resource is None 1944 parent = resource.parent if resource else None 1945 child = resource.child if resource else None 1946 1947 # Expand also_requires dependencies (transitively) so that each 1948 # dependency is resolved within the same batch 1949 expanded = [] 1950 1951 def add_action(name): 1952 if name in expanded: 1953 return 1954 action_obj = self.actions.get(name) 1955 if action_obj is None: 1956 raise ValueError(f"Unknown action: {name}") 1957 expanded.append(name) 1958 if action_obj.also_requires: 1959 add_action(action_obj.also_requires) 1960 ... 1338 more lines, use offset=1960 to continue
Reasoning
We need a summary of how auth works. Provide explanation.
Let's gather more details about permission flow.
Read app.py actor_from_request handling earlier already seen. Let's read around line 2850.
Response
Tool calls
- CodingTools_read_file:
LeIATVFtkY7apuGpeJbeCDA1RpUyCw8v
path:
offset:datasette/app.py2800limit:80
2026-08-10T19:21:43
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
LeIATVFtkY7apuGpeJbeCDA1RpUyCw8v2801 row = results.first() 2802 if row is None: 2803 raise RowNotFound(db.name, table_name, pk_values) 2804 return ResolvedRow(db, table_name, sql, params, pks, pk_values, results.first()) 2805 2806 def app(self): 2807 """Returns an ASGI app function that serves the whole of Datasette""" 2808 routes = self._routes() 2809 2810 async def setup_db(): 2811 # First time server starts up, calculate table counts for immutable databases 2812 for database in self.databases.values(): 2813 if not database.is_mutable: 2814 await database.table_counts(limit=60 * 60 * 1000) 2815 2816 async def _close_on_shutdown(): 2817 self.close() 2818 2819 asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self) 2820 if self.setting("trace_debug"): 2821 asgi = AsgiTracer(asgi) 2822 asgi = AsgiLifespan(asgi, on_shutdown=[_close_on_shutdown]) 2823 asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup]) 2824 for wrapper in pm.hook.asgi_wrapper(datasette=self): 2825 asgi = wrapper(asgi) 2826 return asgi 2827 2828 2829 class DatasetteRouter: 2830 def __init__(self, datasette, routes): 2831 self.ds = datasette 2832 self.routes = routes or [] 2833 2834 async def __call__(self, scope, receive, send): 2835 # Because we care about "foo/bar" v.s. "foo%2Fbar" we decode raw_path ourselves 2836 path = scope["path"] 2837 raw_path = scope.get("raw_path") 2838 if raw_path: 2839 path = raw_path.decode("ascii") 2840 path = path.partition("?")[0] 2841 # Give each request a fresh permission check cache, so repeated 2842 # datasette.allowed() checks within the request are memoized but 2843 # results never persist beyond it 2844 from datasette.permissions import _permission_check_cache 2845 2846 cache_token = _permission_check_cache.set({}) 2847 try: 2848 return await self.route_path(scope, receive, send, path) 2849 finally: 2850 _permission_check_cache.reset(cache_token) 2851 2852 async def route_path(self, scope, receive, send, path): 2853 # Strip off base_url if present before routing 2854 base_url = self.ds.setting("base_url") 2855 if base_url != "/" and path.startswith(base_url): 2856 path = "/" + path[len(base_url) :] 2857 scope = dict(scope, route_path=path) 2858 request = Request( 2859 scope, 2860 receive, 2861 max_post_body_bytes=self.ds.setting("max_post_body_bytes"), 2862 ) 2863 # Populate request_messages if ds_messages cookie is present 2864 try: 2865 request._messages = self.ds.unsign( 2866 request.cookies.get("ds_messages", ""), "messages" 2867 ) 2868 except BadSignature: 2869 pass 2870 2871 scope_modifications = {} 2872 # Apply force_https_urls, if set 2873 if ( 2874 self.ds.setting("force_https_urls") 2875 and scope["type"] == "http" 2876 and scope.get("scheme") != "https" 2877 ): 2878 scope_modifications["scheme"] = "https" 2879 # Handle authentication 2880 default_actor = scope.get("actor") or None ... 418 more lines, use offset=2880 to continue
Reasoning
We have enough info. Now summarize.
We need provide explanation of how auth works in Datasette. Summarize docs and code findings.
Provide overview: actors, authentication via hooks actor_from_request, cookie ds_actor signed with itsdangerous, token auth Bearer dstok, root actor, permissions system, allow blocks, etc.
Probably answer concisely but thorough.
Let's gather more details about default-deny and root.
Read app.py for root handling.
Response
Tool calls
- CodingTools_search_files:
rpJ49mkpIGCioqlo26jDLOUTKo5SxPHA
pattern:
path:root_enableddatasette
2026-08-10T19:22:00
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_search_files:
rpJ49mkpIGCioqlo26jDLOUTKo5SxPHAcli.py:710: ds.root_enabled = True app.py:635: self.root_enabled = False default_permissions/root.py:26: if not datasette.root_enabled:
Response
Tool calls
- CodingTools_read_file:
ckWDwQVvnJASpl1mH6NkzR5vf1JXJjkK
path:datasette/default_permissions/root.py
2026-08-10T19:22:04
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
ckWDwQVvnJASpl1mH6NkzR5vf1JXJjkK1 """ 2 Root user permission handling for Datasette. 3 4 Grants full permissions to the root user when --root flag is used. 5 """ 6 7 from __future__ import annotations 8 9 from typing import TYPE_CHECKING 10 11 if TYPE_CHECKING: 12 from datasette.app import Datasette 13 14 from datasette import hookimpl 15 from datasette.permissions import PermissionSQL 16 17 18 @hookimpl(specname="permission_resources_sql") 19 async def root_user_permissions_sql( 20 datasette: Datasette, 21 actor: dict | None, 22 ) -> PermissionSQL | None: 23 """ 24 Grant root user full permissions when --root flag is used. 25 """ 26 if not datasette.root_enabled: 27 return None 28 if actor is not None and actor.get("id") == "root": 29 return PermissionSQL.allow(reason="root user")
Response
Tool calls
- CodingTools_search_files:
KQ9tHXqAMv5JaUBzNL0GhIKcNMmSY0rF
pattern:
path:actor_from_request
glob:.*.py
2026-08-10T19:22:11
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_search_files:
KQ9tHXqAMv5JaUBzNL0GhIKcNMmSY0rFdatasette/hookspecs.py:102:def actor_from_request(datasette, request): datasette/app.py:2883: results = pm.hook.actor_from_request(datasette=self.ds, request=request) datasette/actor_auth_cookie.py:10:def actor_from_request(datasette, request): datasette/default_permissions/tokens.py:25:@hookimpl(specname="actor_from_request") tests/test_plugins.py:724:async def test_hook_actor_from_request(ds_client): tests/test_plugins.py:734:async def test_hook_actor_from_request_async(ds_client): tests/test_error_shape.py:434: # plugin's actor_from_request hook, so it must not hard-fail tests/fixtures.py:38: "actor_from_request", tests/fixtures.py:70: "actor_from_request", tests/plugins/my_plugin_2.py:103:def actor_from_request(datasette, request): tests/plugins/my_plugin.py:198:def actor_from_request(datasette, request):
Reasoning
We have enough info to summarize. Let's also check tests for auth.
Response
Tool calls
- CodingTools_read_file:
VH3IMWkA5Y1Xy2g7w4yCsglXc1BrJSqD
path:
offset:datasette/app.py1695limit:60
2026-08-10T19:22:19
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
VH3IMWkA5Y1Xy2g7w4yCsglXc1BrJSqD1696 self, 1697 *, 1698 action: str, 1699 actor: dict | None = None, 1700 parent: str | None = None, 1701 include_is_private: bool = False, 1702 ) -> ResourcesSQL: 1703 """ 1704 Build SQL query to get all resources the actor can access for the given action. 1705 1706 Args: 1707 action: The action name (e.g., "view-table") 1708 actor: The actor dict (or None for unauthenticated) 1709 parent: Optional parent filter (e.g., database name) to limit results 1710 include_is_private: If True, include is_private column showing if anonymous cannot access 1711 1712 Returns a namedtuple of (query: str, params: dict) that can be executed against the internal database. 1713 The query returns rows with (parent, child, reason) columns, plus is_private if requested. 1714 1715 Example: 1716 query, params = await datasette.allowed_resources_sql( 1717 action="view-table", 1718 actor=actor, 1719 parent="mydb", 1720 include_is_private=True 1721 ) 1722 result = await datasette.get_internal_database().execute(query, params) 1723 """ 1724 from datasette.utils.actions_sql import build_allowed_resources_sql 1725 1726 action_obj = self.actions.get(action) 1727 if not action_obj: 1728 raise ValueError(f"Unknown action: {action}") 1729 1730 sql, params = await build_allowed_resources_sql( 1731 self, actor, action, parent=parent, include_is_private=include_is_private 1732 ) 1733 return ResourcesSQL(sql, params) 1734 1735 async def allowed_resources( 1736 self, 1737 action: str, 1738 actor: dict | None = None, 1739 *, 1740 parent: str | None = None, 1741 include_is_private: bool = False, 1742 include_reasons: bool = False, 1743 limit: int = 100, 1744 next: str | None = None, 1745 ) -> PaginatedResources: 1746 """ 1747 Return paginated resources the actor can access for the given action. 1748 1749 Uses SQL with keyset pagination to efficiently filter resources. 1750 Returns PaginatedResources with list of Resource instances and pagination metadata. 1751 1752 Args: 1753 action: The action name (e.g., "view-table") 1754 actor: The actor dict (or None for unauthenticated) 1755 parent: Optional parent filter (e.g., database name) to limit results ... 1543 more lines, use offset=1755 to continue
Reasoning
We have enough to explain. Let's summarize auth flow.
Key points:
- Auth is plugin-based via actor_from_request hook
- Default handlers: actor_auth_cookie (ds_actor cookie), token handler via Bearer header
- Root user with --root flag, single-use login URL
- request.actor set in DatasetteRouter.route_path, using scope modifications and calling pm.hook.actor_from_request
- Actor dict structure, display keys
- Permissions system: allowed() checks actor, action, resource using SQL built from allow blocks, plugins permission_resources_sql, root rule, token restrictions.
- Permissions resolved with specificity: resource > parent > global, deny overrides allow at same level.
- default-deny flag
- API tokens: signed tokens via dstok_, created via CLI or UI, can have restrictions (_r)
- Permission checks cached per request via contextvar
Let's read more about sign/unsign.
Response
Tool calls
- CodingTools_search_files:
jtweUy2PWyc0U4hQqd3pEMBZXcicjeCc
pattern:
path:def signdatasette
2026-08-10T19:22:39
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_search_files:
jtweUy2PWyc0U4hQqd3pEMBZXcicjeCcapp.py:830: def sign(self, value, namespace="default"):
Response
Tool calls
- CodingTools_read_file:
XaNOOd8zMThZ5Q9gFrV704nm4nAO4G6r
path:
offset:datasette/app.py825limit:30
2026-08-10T19:22:45
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
XaNOOd8zMThZ5Q9gFrV704nm4nAO4G6r826 for hook in pm.hook.startup(datasette=self): 827 await await_me_maybe(hook) 828 self._startup_invoked = True 829 830 def sign(self, value, namespace="default"): 831 return URLSafeSerializer(self._secret, namespace).dumps(value) 832 833 def unsign(self, signed, namespace="default"): 834 return URLSafeSerializer(self._secret, namespace).loads(signed) 835 836 def in_client(self) -> bool: 837 """Check if the current code is executing within a datasette.client request. 838 839 Returns: 840 bool: True if currently executing within a datasette.client request, False otherwise. 841 """ 842 return _in_datasette_client.get() 843 844 def _token_handlers(self): 845 """Collect all registered token handlers from plugins.""" 846 from datasette.tokens import TokenHandler 847 848 handlers = [] 849 for result in pm.hook.register_token_handler(datasette=self): 850 if isinstance(result, TokenHandler): 851 handlers.append(result) 852 elif isinstance(result, list): 853 handlers.extend(h for h in result if isinstance(h, TokenHandler)) 854 return handlers 855 ... 2443 more lines, use offset=855 to continue
Response
Tool calls
- CodingTools_read_file:
VNN5PdZ0fCU71u1vakj9r44QKRosmt4G
path:
offset:datasette/app.py870limit:40
2026-08-10T19:22:52
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
VNN5PdZ0fCU71u1vakj9r44QKRosmt4G871 can perform. 872 """ 873 handlers = self._token_handlers() 874 if not handlers: 875 raise RuntimeError("No token handlers are registered") 876 877 if handler is not None: 878 matched = [h for h in handlers if h.name == handler] 879 if not matched: 880 available = [h.name for h in handlers] 881 raise ValueError( 882 f"Token handler {handler!r} not found. " 883 f"Available handlers: {available}" 884 ) 885 chosen = matched[0] 886 else: 887 chosen = handlers[0] 888 889 return await chosen.create_token( 890 self, 891 actor_id, 892 expires_after=expires_after, 893 restrictions=restrictions, 894 ) 895 896 async def verify_token(self, token: str) -> dict | None: 897 """ 898 Verify an API token by trying all registered token handlers. 899 900 Returns an actor dict from the first handler that recognizes the 901 token, or None if no handler accepts it. A handler may raise 902 TokenInvalid for a token it recognizes but rejects (bad signature, 903 expired) - Datasette turns that into a 401 response. 904 """ 905 for token_handler in self._token_handlers(): 906 result = await token_handler.verify_token(self, token) 907 if result is not None: 908 return result 909 return None 910 ... 2388 more lines, use offset=910 to continue
Reasoning
Let's look at app.py around where actor_from_request is used and how authentication works in detail.
Response
Tool calls
- CodingTools_read_file:
a1kQTbUSXi0jXAawWXXBKpNbjR9yMNJ4
path:
offset:datasette/app.py2879limit:40
2026-08-10T19:23:01
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
a1kQTbUSXi0jXAawWXXBKpNbjR9yMNJ42880 default_actor = scope.get("actor") or None 2881 actor = None 2882 token_error = None 2883 results = pm.hook.actor_from_request(datasette=self.ds, request=request) 2884 for result in results: 2885 try: 2886 result = await await_me_maybe(result) 2887 except TokenInvalid as ex: 2888 # A presented token was recognized but rejected - fail the 2889 # request with a 401 even if another credential is valid, 2890 # but keep awaiting the remaining coroutines first 2891 if token_error is None: 2892 token_error = ex 2893 continue 2894 if result and actor is None: 2895 actor = result 2896 # Don't break — we must await all coroutines to avoid 2897 # "coroutine was never awaited" warnings 2898 if token_error is not None: 2899 return await self.handle_401(request, send, token_error) 2900 scope_modifications["actor"] = actor or default_actor 2901 scope = dict(scope, **scope_modifications) 2902 2903 match, view = resolve_routes(self.routes, path) 2904 2905 if match is None: 2906 return await self.handle_404(request, send) 2907 2908 new_scope = dict(scope, url_route={"kwargs": match.groupdict()}) 2909 request.scope = new_scope 2910 try: 2911 response = await view(request, send) 2912 if response: 2913 self.ds._write_messages_to_response(request, response) 2914 await response.asgi_send(send) 2915 return 2916 except NotFound as exception: 2917 return await self.handle_404(request, send, exception) 2918 except Forbidden as exception: 2919 # Try the forbidden() plugin hook ... 379 more lines, use offset=2919 to continue
Reasoning
Now we need a concise explanation. Provide summary.
Let's read tests for auth to see flow.
Response
Tool calls
- CodingTools_read_file:
KrxPTfDxCTb7QxOyC7ESXgmIzfLn9q6D
path:
offset:tests/test_auth.py200limit:130
2026-08-10T19:23:12
Prompt
-- none --
Tools
CodingTools({"root": "."}):- CodingTools_edit_file:
0c5e06b - CodingTools_execute_command:
87b7dfe - CodingTools_list_files:
4d779da - CodingTools_read_file:
d952137 - CodingTools_search_files:
6b94785 - CodingTools_write_file:
0678eb4
- CodingTools_edit_file:
Tool results
- CodingTools_read_file:
KrxPTfDxCTb7QxOyC7ESXgmIzfLn9q6D201 response2 = app_client.post( 202 "/-/create-token", 203 post_data, 204 csrftoken_from=True, 205 cookies={"ds_actor": ds_actor}, 206 ) 207 assert response2.status == 200 208 if errors: 209 for error in errors: 210 assert f'<p class="message-error">{error}</p>' in response2.text 211 else: 212 # Check create-token event 213 event = last_event(app_client.ds) 214 assert event.name == "create-token" 215 assert event.expires_after == expected_duration 216 assert isinstance(event.restrict_all, list) 217 assert isinstance(event.restrict_database, dict) 218 assert isinstance(event.restrict_resource, dict) 219 # Extract token from page 220 token = response2.text.split('value="dstok_')[1].split('"')[0] 221 details = app_client.ds.unsign(token, "token") 222 if expected_r: 223 r = details.pop("_r") 224 assert r == expected_r 225 assert details.keys() == {"a", "t", "d"} or details.keys() == {"a", "t"} 226 assert details["a"] == "test" 227 if expected_duration is None: 228 assert "d" not in details 229 else: 230 assert details["d"] == expected_duration 231 # And test that token 232 response3 = app_client.get( 233 "/-/actor.json", 234 headers={"Authorization": "Bearer {}".format(f"dstok_{token}")}, 235 ) 236 assert response3.status == 200 237 assert response3.json["actor"]["id"] == "test" 238 239 240 @pytest.mark.asyncio 241 async def test_auth_create_token_not_allowed_for_tokens(ds_client): 242 ds_tok = ds_client.ds.sign( 243 {"a": "test", "token": "dstok", "t": int(time.time())}, "token" 244 ) 245 response = await ds_client.get( 246 "/-/create-token", 247 headers={"Authorization": f"Bearer dstok_{ds_tok}"}, 248 ) 249 assert response.status_code == 403 250 251 252 @pytest.mark.asyncio 253 async def test_auth_create_token_not_allowed_if_allow_signed_tokens_off(ds_client): 254 ds_client.ds._settings["allow_signed_tokens"] = False 255 try: 256 ds_actor = ds_client.actor_cookie({"id": "test"}) 257 response = await ds_client.get( 258 "/-/create-token", cookies={"ds_actor": ds_actor} 259 ) 260 assert response.status_code == 403 261 finally: 262 ds_client.ds._settings["allow_signed_tokens"] = True 263 264 265 @pytest.mark.asyncio 266 @pytest.mark.parametrize( 267 "scenario,should_work", 268 ( 269 ("allow_signed_tokens_off", False), 270 ("no_token", False), 271 ("no_timestamp", False), 272 ("invalid_token", False), 273 ("expired_token", False), 274 ("valid_unlimited_token", True), 275 ("valid_expiring_token", True), 276 ), 277 ) 278 async def test_auth_with_dstok_token(ds_client, scenario, should_work): 279 token = None 280 _time = int(time.time()) 281 if scenario in ("valid_unlimited_token", "allow_signed_tokens_off"): 282 token = ds_client.ds.sign({"a": "test", "t": _time}, "token") 283 elif scenario == "valid_expiring_token": 284 token = ds_client.ds.sign({"a": "test", "t": _time - 50, "d": 1000}, "token") 285 elif scenario == "expired_token": 286 token = ds_client.ds.sign({"a": "test", "t": _time - 2000, "d": 1000}, "token") 287 elif scenario == "no_timestamp": 288 token = ds_client.ds.sign({"a": "test"}, "token") 289 elif scenario == "invalid_token": 290 token = "invalid" 291 if token: 292 token = f"dstok_{token}" 293 if scenario == "allow_signed_tokens_off": 294 ds_client.ds._settings["allow_signed_tokens"] = False 295 headers = {} 296 if token: 297 headers["Authorization"] = f"Bearer {token}" 298 response = await ds_client.get("/-/actor.json", headers=headers) 299 try: 300 if should_work: 301 data = response.json() 302 assert data.keys() == {"ok", "actor"} 303 actor = data["actor"] 304 expected_keys = {"id", "token"} 305 if scenario != "valid_unlimited_token": 306 expected_keys.add("token_expires") 307 assert actor.keys() == expected_keys 308 assert actor["id"] == "test" 309 assert actor["token"] == "dstok" 310 if scenario != "valid_unlimited_token": 311 assert isinstance(actor["token_expires"], int) 312 elif scenario == "no_token": 313 # No credentials presented - request proceeds as anonymous 314 assert response.json() == {"ok": True, "actor": None} 315 else: 316 # Invalid credentials presented - hard 401 317 assert response.status_code == 401 318 data = response.json() 319 assert data["ok"] is False 320 assert data["status"] == 401 321 assert response.headers["www-authenticate"].startswith("Bearer") 322 finally: 323 ds_client.ds._settings["allow_signed_tokens"] = True 324 325 326 @pytest.mark.parametrize("expires", (None, 1000, -1000)) 327 def test_cli_create_token(app_client, expires): 328 secret = app_client.ds._secret 329 runner = CliRunner() 330 args = ["create-token", "--secret", secret, "test"] ... 196 more lines, use offset=330 to continue
Reasoning
Provide summary.
Response
Datasette’s authentication is plugin-driven and built around the concept of an actor – a JSON dict that represents who is making the request.
-
Actor resolution
- Every ASGI request goes through
DatasetteRouter.route_path→pm.hook.actor_from_request(datasette=self.ds, request=request). - Hooks are run for each request:
datasette.actor_auth_cookie– reads the signedds_actorcookie. The cookie value issign({"a": <actor>, "e": <expiry base62>}, "actor"). On read it verifies the signature withitsdangerous, checks the optional expiry and returnsdecoded["a"].datasette.default_permissions.tokens.actor_from_signed_api_token– looks forAuthorization: Bearer <token>, delegates todatasette.verify_token(token). Token handlers are registered via theregister_token_handlerhook.- Plugins can implement
actor_from_requestthemselves. The first non-None result wins, but all coroutines are awaited so no “coroutine was never awaited” warnings appear.
request.actoris then put into the ASGI scope for the whole request. If a token handler raisesTokenInvalidthe request is short-circuited with HTTP 401 andWWW-Authenticate: Bearer error="invalid_token". - Every ASGI request goes through
-
Cookie auth
- Signed with
datasette.sign(value, namespace="actor")usingURLSafeSerializer+DATASETTE_SECRET. - Payload shape:
{"a": <actor dict>, "e": <base62 timestamp>}. Expiry is checked on every request. - Login flow for the built-in root user:
--rootsetsDatasette.root_enabled = True. A single-use token is printed on startup./-/auth-token?token=...signs{"a":{"id":"root"}}intods_actorand emits aloginevent./-/logoutclears the cookie and emitslogout.
- Signed with
-
API token auth
- Default handler:
datasette.tokens.SignedTokenHandler, registered bydatasette.default_permissions.tokens. - Tokens are
dstok_<itsdangerous signature>signed with namespace"token". Payload:{ "a": "<actor_id>", "t": <created_ts>, "d": <duration_seconds?>, "_r": { "a":[], "d":{db:[actions]}, "r":{db:{resource:[actions]}} } // optional restrictions } datasette.create_token(actor_id, expires_after=None, restrictions=TokenRestrictions|None)creates the signed token. CLI:datasette create-token <id> --expires-after … --all/--database/--resource ….verify_tokenchecksallow_signed_tokenssetting, signature, timestamp and max TTL, returns actor dict:with optional{"id": "user_id", "token": "dstok", "token_expires": 123456}"_r"restrictions copied from the token.- Restrictions are an allow-list layered on top of normal permission rules – they can only remove access.
- Default handler:
-
Permissions
- The central check is
datasette.allowed(action, resource?, actor?)→allowed_many()→check_permissions_for_actions. - Permissions are resolved via SQL built from:
allowblocks indatasette.yaml/datasette.json- Actor restrictions encoded in the token
- Root rule when
root_enabledis true:datasette.default_permissions.rootreturnsPermissionSQL.allow(reason="root user")for actor{"id":"root"} - Plugin contributions via
permission_resources_sqlhook returningPermissionSQLwithparent, child, allow, reasoncolumns
- Resolution order: resource > parent > global; deny beats allow at same level; no match = deny. Actions can
also_requiresanother action – evaluated as AND. - Built-in defaults allow unauthenticated
view-instance,view-database,view-table, etc.--default-denyremoves those global allows. - Helpers:
datasette.ensure_permission, debug views/-/permissions,/-/allowed,/-/rules,/-/allow-debug.
- The central check is
-
Display For UI display Datasette picks the first non-empty key in order:
display,name,username,login,id. If none, the whole dict is stringified.
That’s it – authentication is “who are you?” via actor_from_request hooks/cookies/tokens, and authorization is “is this actor allowed to do this action on this resource?” via the SQL-based permission system with config/plugin rules.