262588213843476 · Gist

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:
    **/*auth*
    
    path:
    .
    

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

Tool results

  • CodingTools_list_files: Zi2VZTkkx3xzqABuk9FcMtx79gpTE9sX
    tests/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

Tool results

  • CodingTools_list_files: mkYUELgVQJcG8K1aMpOznIIZTq8rTEFV
    version.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

Tool results

  • CodingTools_read_file: izzJ3Uhp26dGZ3zDPmKVdpLTdPEb2Hdx
         1	.. _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

Tool results

  • CodingTools_read_file: Brcx8ynSJT8mS3gE6DxIiwMDduG11xnf
         1	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

Tool results

  • CodingTools_read_file: xeLCgoNdhJ18DWbzUvjEKRl0C9JnRgDt
         1	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:
    actor_from_request
    
    path:
    datasette
    

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

Tool results

  • CodingTools_search_files: PxZtyVjeaI5xComl1XNI6RBuxUZx4fea
    hookspecs.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

Tool results

  • CodingTools_read_file: 8tuydaN8PhHCvGGZlyMT2L4XZLilR2vn
         1	"""
         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

Tool results

  • CodingTools_read_file: rcLYqUexscRXOhLvYJlkDzp5iEfx1Sb9
         1	"""
         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:
    datasette/app.py
    
    offset: 2870 limit: 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

Tool results

  • CodingTools_read_file: Aosw5X55tNjKUJLloiHmeW1Hwhlewhsj
      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
      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

Tool results

  • CodingTools_read_file: uLw9omhq3LdIACnlot3faUOYp7OlP6Dc
         1	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:
    tests/test_auth.py
    
    limit: 200

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

Tool results

  • CodingTools_read_file: t42sYN9OcC0uIKXVuw2AmfbSaOfcWFK0
         1	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>{&#39;name2&#39;: &#39;bob&#39;}</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:
    request.actor
    
    path:
    datasette
    
    max_results: 50

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

Tool results

  • CodingTools_search_files: JanUSGis98tTqmoUpoOmooFEEVSZCr6p
    filters.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:
    def allowed
    
    path:
    datasette
    

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

Tool results

  • CodingTools_search_files: SA6CsXgy2FGFJOnqXzOdCdhyo4vVMq1l
    app.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:
    datasette/app.py
    
    offset: 1880 limit: 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

Tool results

  • CodingTools_read_file: 6u72T11sRj9oBG4UbFAJvq230WZUN2X5
      1881	    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:
    datasette/app.py
    
    offset: 2800 limit: 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

Tool results

  • CodingTools_read_file: LeIATVFtkY7apuGpeJbeCDA1RpUyCw8v
      2801	        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:
    root_enabled
    
    path:
    datasette
    

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

Tool results

  • CodingTools_search_files: rpJ49mkpIGCioqlo26jDLOUTKo5SxPHA
    cli.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

Tool results

  • CodingTools_read_file: ckWDwQVvnJASpl1mH6NkzR5vf1JXJjkK
         1	"""
         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:
    actor_from_request
    
    path:
    .
    
    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

Tool results

  • CodingTools_search_files: KQ9tHXqAMv5JaUBzNL0GhIKcNMmSY0rF
    datasette/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:
    datasette/app.py
    
    offset: 1695 limit: 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

Tool results

  • CodingTools_read_file: VH3IMWkA5Y1Xy2g7w4yCsglXc1BrJSqD
      1696	        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:
    def sign
    
    path:
    datasette
    

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

Tool results

  • CodingTools_search_files: jtweUy2PWyc0U4hQqd3pEMBZXcicjeCc
    app.py:830:    def sign(self, value, namespace="default"):
    

Response

Tool calls

  • CodingTools_read_file: XaNOOd8zMThZ5Q9gFrV704nm4nAO4G6r
    path:
    datasette/app.py
    
    offset: 825 limit: 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

Tool results

  • CodingTools_read_file: XaNOOd8zMThZ5Q9gFrV704nm4nAO4G6r
       826	        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:
    datasette/app.py
    
    offset: 870 limit: 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

Tool results

  • CodingTools_read_file: VNN5PdZ0fCU71u1vakj9r44QKRosmt4G
       871	        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:
    datasette/app.py
    
    offset: 2879 limit: 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

Tool results

  • CodingTools_read_file: a1kQTbUSXi0jXAawWXXBKpNbjR9yMNJ4
      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
    ... 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:
    tests/test_auth.py
    
    offset: 200 limit: 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

Tool results

  • CodingTools_read_file: KrxPTfDxCTb7QxOyC7ESXgmIzfLn9q6D
       201	    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_pathpm.hook.actor_from_request(datasette=self.ds, request=request).
    • Hooks are run for each request:
      • datasette.actor_auth_cookie – reads the signed ds_actor cookie. The cookie value is sign({"a": <actor>, "e": <expiry base62>}, "actor"). On read it verifies the signature with itsdangerous, checks the optional expiry and returns decoded["a"].
      • datasette.default_permissions.tokens.actor_from_signed_api_token – looks for Authorization: Bearer <token>, delegates to datasette.verify_token(token). Token handlers are registered via the register_token_handler hook.
      • Plugins can implement actor_from_request themselves. The first non-None result wins, but all coroutines are awaited so no “coroutine was never awaited” warnings appear.

    request.actor is then put into the ASGI scope for the whole request. If a token handler raises TokenInvalid the request is short-circuited with HTTP 401 and WWW-Authenticate: Bearer error="invalid_token".

  • Cookie auth

    • Signed with datasette.sign(value, namespace="actor") using URLSafeSerializer + DATASETTE_SECRET.
    • Payload shape: {"a": <actor dict>, "e": <base62 timestamp>}. Expiry is checked on every request.
    • Login flow for the built-in root user:
      • --root sets Datasette.root_enabled = True. A single-use token is printed on startup.
      • /-/auth-token?token=... signs {"a":{"id":"root"}} into ds_actor and emits a login event.
      • /-/logout clears the cookie and emits logout.
  • API token auth

    • Default handler: datasette.tokens.SignedTokenHandler, registered by datasette.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_token checks allow_signed_tokens setting, signature, timestamp and max TTL, returns actor dict:
      {"id": "user_id", "token": "dstok", "token_expires": 123456}
      with optional "_r" restrictions copied from the token.
    • Restrictions are an allow-list layered on top of normal permission rules – they can only remove access.
  • Permissions

    • The central check is datasette.allowed(action, resource?, actor?)allowed_many()check_permissions_for_actions.
    • Permissions are resolved via SQL built from:
      • allow blocks in datasette.yaml / datasette.json
      • Actor restrictions encoded in the token
      • Root rule when root_enabled is true: datasette.default_permissions.root returns PermissionSQL.allow(reason="root user") for actor {"id":"root"}
      • Plugin contributions via permission_resources_sql hook returning PermissionSQL with parent, child, allow, reason columns
    • Resolution order: resource > parent > global; deny beats allow at same level; no match = deny. Actions can also_requires another action – evaluated as AND.
    • Built-in defaults allow unauthenticated view-instance, view-database, view-table, etc. --default-deny removes those global allows.
    • Helpers: datasette.ensure_permission, debug views /-/permissions, /-/allowed, /-/rules, /-/allow-debug.
  • 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.

Read the original on gist.github.com ↗