Skip to content

Instantly share code, notes, and snippets.

@simonw
Created August 10, 2026 19:29
Show Gist options
  • Select an option

  • Save simonw/3623676275da1a6078b2500dcc78116f to your computer and use it in GitHub Desktop.

Select an option

Save simonw/3623676275da1a6078b2500dcc78116f to your computer and use it in GitHub Desktop.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment