Skip to content

Instantly share code, notes, and snippets.

@simonw
Created August 16, 2026 18:55
Show Gist options
  • Select an option

  • Save simonw/6693d74a6bd45f641d43ceb9961dd95f to your computer and use it in GitHub Desktop.

Select an option

Save simonw/6693d74a6bd45f641d43ceb9961dd95f to your computer and use it in GitHub Desktop.

Session Transcript

  • Session ID: 01a006d4-fa13-76b4-83f3-1a0ab01392c5
  • Model: spark/qwen3.8-27b
  • Working dir: /Users/simon/Dropbox/dev/datasette
  • Thinking level: low

💬 User

how does auth work?

🧠 Thinking

The user is asking "How does authentication work?" in the context of the Datasette project. Let's look at the codebase and understand how authentication works in Datasette.

First, let's start by exploring the repository structure.

🔧 bash

{
  "command": "ls /Users/simon/Dropbox/dev/datasette && ls /Users/simon/Dropbox/dev/datasette/datasette 2>/dev/null | head -50"
}

↩️ bash result

1.0-release-notes.md
1.0-release-report.md
benchmarks
blogs.db
build
CODE_OF_CONDUCT.md
codecov.yml
content.db
data.db
datasette
datasette.egg-info
demos
dist
Dockerfile
docs
fixtures.db
hello.db
htmlcov
ignored
internal-edit-demo.db
internal-operation-sql.db
internal-queries.db
internal-sql-write.db
internal.db
issues.db
jump-sections-demo-plugin
Justfile
LICENSE
MANIFEST.in
node_modules
package-lock.json
package.json
Pipfile
pyproject.toml
pytest.ini
python-3.14-venv
python3.14-venv
README.md
ruff.toml
scratch
setup.cfg
test-in-pyodide-with-shot-scraper.sh
tests
tmp-vacuum-copy2.db
tmp.db
uv.lock
venv
__init__.py
__main__.py
__pycache__
_pytest_plugin.py
actor_auth_cookie.py
app.py
blob_renderer.py
cli.py
column_types.py
csrf.py
database.py
default_actions.py
default_column_types.py
default_database_actions.py
default_debug_menu.py
default_jump_items.py
default_magic_parameters.py
default_permissions
default_query_actions.py
default_table_actions.py
events.py
extras.py
facets.py
filters.py
fixtures.py
forbidden.py
handle_exception.py
hookspecs.py
inspect.py
jump.py
permissions.py
plugins.py
publish
renderer.py
resources.py
sql_functions.py
static
stored_queries.py
template_contexts.py
templates
tokens.py
tracer.py
url_builder.py
utils
vendored
version.py
views
write_sql.py
🧠 Thinking

This is the Datasette project. Let's take a look at how authentication works. Key files: actor_auth_cookie.py, permissions.py, csrf.py, and probably docs/authentication.md. I'll try reading these.

🔧 read

{
  "path": "/Users/simon/Dropbox/dev/datasette/datasette/actor_auth_cookie.py"
}

🔧 bash

{
  "command": "ls /Users/simon/Dropbox/dev/datasette/docs | head -60 && ls /Users/simon/Dropbox/dev/datasette/datasette/views"
}

↩️ read result

import time

from itsdangerous import BadSignature

from datasette import hookimpl
from datasette.utils import baseconv


@hookimpl
def actor_from_request(datasette, request):
    if "ds_actor" not in request.cookies:
        return None
    try:
        decoded = datasette.unsign(request.cookies["ds_actor"], "actor")
        # If it has "e" and "a" keys process the "e" expiry
        if not isinstance(decoded, dict) or "a" not in decoded:
            return None
        expires_at = decoded.get("e")
        if expires_at:
            timestamp = int(baseconv.base62.decode(expires_at))
            if time.time() > timestamp:
                return None
        return decoded["a"]
    except BadSignature:
        return None

↩️ bash result

__pycache__
_build
_static
_templates
authentication.rst
auto-build.sh
binary_data.rst
changelog.rst
cli-reference.rst
codespell-ignore-words.txt
conf.py
configuration.rst
contributing.rst
csv_export.rst
custom_templates.rst
datasette-0.51.png
datasette-logo.svg
deploying.rst
ecosystem.rst
events.md
facets.rst
full_text_search.rst
getting_started.rst
index.rst
installation.rst
internals.rst
introspection.rst
javascript_plugins.rst
json_api_doc.py
json_api.rst
Makefile
metadata_doc.py
metadata.rst
pages.rst
performance.rst
plugin_hooks.rst
plugins.rst
publish.rst
settings.rst
spatialite.rst
sql_queries.rst
template_context_doc.py
template_context.rst
testing_plugins.rst
upgrade_guide.md
upgrade-1.0a20.md
writing_plugins.rst
__init__.py
__pycache__
base.py
database.py
execute_write.py
index.py
query_helpers.py
row.py
special.py
stored_queries.py
table_create_alter.py
table_extras.py
table.py
🧠 Thinking

Let's take a look at the authentication documentation and the main app code related to actor/authentication. Also, let's check actor_from_request in app.py, unsign, and permissions.py.

🔧 bash

{
  "command": "grep -n \"actor\" /Users/simon/Dropbox/dev/datasette/datasette/app.py | head -40; echo ---; grep -rn \"def unsign\\|def sign\\b\" /Users/simon/Dropbox/dev/datasette/datasette/"
}

↩️ bash result

66:    display_actor,
188:    actor: dict[str, Any] | None
312:def _permission_cache_key(actor, action, parent, child):
313:    # Key on the full serialized actor so actors differing in any field
315:    actor_key = (
316:        json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None
318:    return (actor_key, action, parent, child)
376:    "request": "The current :ref:`Request object <internals_request>`, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.",
379:    "actor": "The currently authenticated actor dictionary, or None. Actors usually include an ``id`` key and may include any other keys supplied by authentication plugins.",
381:    "display_actor": "Function that accepts an actor dictionary and returns the display string used in the navigation menu.",
858:        actor_id: str,
865:        Create an API token for the given actor.
891:            actor_id,
900:        Returns an actor dict from the first handler that recognizes the
1219:        actor: dict[str, Any] | None = None,
1230:            actor=actor,
1243:        actor: dict[str, Any] | None = None,
1257:            actor=actor,
1270:        self, database, sql, *, actor=None, params=None, analysis=None
1274:            self, database, sql, actor=actor, params=params, analysis=analysis
1529:        conn.row_factory = sqlite3.Row
1530:        conn.text_factory = lambda x: str(x, "utf-8", "replace")
1584:        actor = None
1586:            actor = request.actor
1588:        if await self.allowed(action="view-instance", actor=actor):
1594:            actor=actor,
1608:                actor=actor,
1618:    async def actors_from_ids(
1619:        self, actor_ids: Iterable[str | int]
1621:        result = pm.hook.actors_from_ids(datasette=self, actor_ids=actor_ids)
1624:            return {actor_id: {"id": actor_id} for actor_id in actor_ids}
1666:        actor: dict,
1671:        Check if actor can see a resource and if it's private.
1674:        - visible: bool - can the actor see it?
1683:        # Check if actor can see it
1684:        if not await self.allowed(action=action, resource=resource, actor=actor):
1688:        if not await self.allowed(action=action, resource=resource, actor=None):
1692:        # Both actor and anonymous can see it - it's public
1699:        actor: dict | None = None,
1704:        Build SQL query to get all resources the actor can access for the given action.
---
/Users/simon/Dropbox/dev/datasette/datasette/app.py:830:    def sign(self, value, namespace="default"):
/Users/simon/Dropbox/dev/datasette/datasette/app.py:833:    def unsign(self, signed, namespace="default"):
🧠 Thinking

Let's check the relevant parts of app.py (sign/unsign, actor_from_request, token creation) and permissions.py. I'll read a few sections.

🔧 read

{
  "path": "/Users/simon/Dropbox/dev/datasette/datasette/app.py",
  "offset": 820,
  "limit": 120
}

🔧 bash

{
  "command": "grep -n \"actor_from_request\\|def dispatch\\|request.actor\" /Users/simon/Dropbox/dev/datasette/datasette/app.py | head; echo ---; sed -n '1,60p' /Users/simon/Dropbox/dev/datasette/docs/authentication.rst"
}

↩️ read result

            await await_me_maybe(hook)
        # Ensure internal tables and metadata are populated before startup hooks
        await self._refresh_schemas()
        await self._save_queries_from_config()
        # Load column_types from config into internal DB
        await self._apply_column_types_config()
        for hook in pm.hook.startup(datasette=self):
            await await_me_maybe(hook)
        self._startup_invoked = True

    def sign(self, value, namespace="default"):
        return URLSafeSerializer(self._secret, namespace).dumps(value)

    def unsign(self, signed, namespace="default"):
        return URLSafeSerializer(self._secret, namespace).loads(signed)

    def in_client(self) -> bool:
        """Check if the current code is executing within a datasette.client request.

        Returns:
            bool: True if currently executing within a datasette.client request, False otherwise.
        """
        return _in_datasette_client.get()

    def _token_handlers(self):
        """Collect all registered token handlers from plugins."""
        from datasette.tokens import TokenHandler

        handlers = []
        for result in pm.hook.register_token_handler(datasette=self):
            if isinstance(result, TokenHandler):
                handlers.append(result)
            elif isinstance(result, list):
                handlers.extend(h for h in result if isinstance(h, TokenHandler))
        return handlers

    async def create_token(
        self,
        actor_id: str,
        *,
        expires_after: int | None = None,
        restrictions: TokenRestrictions | None = None,
        handler: str | None = None,
    ) -> str:
        """
        Create an API token for the given actor.

        Uses the first registered token handler by default, or a specific
        handler if ``handler`` is provided (matched by handler name).

        Pass a :class:`TokenRestrictions` to limit which actions the token
        can perform.
        """
        handlers = self._token_handlers()
        if not handlers:
            raise RuntimeError("No token handlers are registered")

        if handler is not None:
            matched = [h for h in handlers if h.name == handler]
            if not matched:
                available = [h.name for h in handlers]
                raise ValueError(
                    f"Token handler {handler!r} not found. "
                    f"Available handlers: {available}"
                )
            chosen = matched[0]
        else:
            chosen = handlers[0]

        return await chosen.create_token(
            self,
            actor_id,
            expires_after=expires_after,
            restrictions=restrictions,
        )

    async def verify_token(self, token: str) -> dict | None:
        """
        Verify an API token by trying all registered token handlers.

        Returns an actor dict from the first handler that recognizes the
        token, or None if no handler accepts it. A handler may raise
        TokenInvalid for a token it recognizes but rejects (bad signature,
        expired) - Datasette turns that into a 401 response.
        """
        for token_handler in self._token_handlers():
            result = await token_handler.verify_token(self, token)
            if result is not None:
                return result
        return None

    def get_database(self, name=None, route=None):
        if route is not None:
            matches = [db for db in self.databases.values() if db.route == route]
            if not matches:
                raise KeyError
            return matches[0]
        if name is None:
            name = next(iter(self.databases.keys()))
        return self.databases[name]

    def add_database(self, db, name=None, route=None):
        new_databases = self.databases.copy()
        if name is None:
            # Pick a unique name for this database
            suggestion = db.suggest_name()
            name = suggestion
        else:
            suggestion = name
        i = 2
        while name in self.databases:
            name = f"{suggestion}_{i}"
            i += 1
        db.name = name
        db.route = route or name
        new_databases[name] = db
        # don't mutate! that causes race conditions with live import
        self.databases = new_databases
        return db


[2360 more lines in file. Use offset=940 to continue.]

↩️ bash result

376:    "request": "The current :ref:`Request object <internals_request>`, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.",
1586:            actor = request.actor
2047:                actor=request.actor
2053:                actor=request.actor
2164:        return {"databases": await self._connected_databases_for_actor(request.actor)}
2282:        return {"actor": request.actor}
2396:                actor=request.actor if request else None,
2411:            "actor": request.actor if request else None,
2416:            and request.actor,
2883:        results = pm.hook.actor_from_request(datasette=self.ds, request=request)
---
.. _authentication:

================================
 Authentication and permissions
================================

Datasette doesn't require authentication by default. Any visitor to a Datasette instance can explore the full data and execute read-only SQL queries.

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.

.. _authentication_actor:

Actors
======

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.

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.

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.

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.

.. _authentication_actor_display:

How actors are displayed
------------------------

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.

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:

* ``display``
* ``name``
* ``username``
* ``login``
* ``id``

If none of those keys have a value the actor dictionary is displayed as a string instead.

.. _authentication_root:

Using the "root" actor
----------------------

Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.

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.

The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:

* All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.)
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``create-view``, ``alter-table``, ``set-column-type``, ``drop-table``, ``drop-view``)
* Debug permissions (``permissions-debug``, ``debug-menu``)
* Any custom permissions defined by plugins

If you add explicit deny rules in ``datasette.yaml`` those can still block the
root actor from specific databases or tables.

The ``--root`` flag sets an internal ``root_enabled`` switch—without it, a signed-in user with ``{"id": "root"}`` is treated like any other actor.
🧠 Thinking

Let me check around line 2883 of app.py (where the actor_from_request hook is called) and take a look at how requests are handled / how session cookies get set, etc. I'll also briefly check permissions.py for allowed(). Additionally, let me verify where the ds_actor cookie gets set — that's done by plugins like datasette-auth-cookies (sign_in). Let's grep for "ds_actor" and "set_cookie".

🔧 bash

{
  "command": "sed -n 2860,2910p /Users/simon/Dropbox/dev/datasette/datasette/app.py; echo ---; grep -rn \"ds_actor\\|sign_in\" /Users/simon/Dropbox/dev/datasette/datasette --include=*.py | grep -v test | head -20"
}

↩️ bash result

            receive,
            max_post_body_bytes=self.ds.setting("max_post_body_bytes"),
        )
        # Populate request_messages if ds_messages cookie is present
        try:
            request._messages = self.ds.unsign(
                request.cookies.get("ds_messages", ""), "messages"
            )
        except BadSignature:
            pass

        scope_modifications = {}
        # Apply force_https_urls, if set
        if (
            self.ds.setting("force_https_urls")
            and scope["type"] == "http"
            and scope.get("scheme") != "https"
        ):
            scope_modifications["scheme"] = "https"
        # Handle authentication
        default_actor = scope.get("actor") or None
        actor = None
        token_error = None
        results = pm.hook.actor_from_request(datasette=self.ds, request=request)
        for result in results:
            try:
                result = await await_me_maybe(result)
            except TokenInvalid as ex:
                # A presented token was recognized but rejected - fail the
                # request with a 401 even if another credential is valid,
                # but keep awaiting the remaining coroutines first
                if token_error is None:
                    token_error = ex
                continue
            if result and actor is None:
                actor = result
                # Don't break — we must await all coroutines to avoid
                # "coroutine was never awaited" warnings
        if token_error is not None:
            return await self.handle_401(request, send, token_error)
        scope_modifications["actor"] = actor or default_actor
        scope = dict(scope, **scope_modifications)

        match, view = resolve_routes(self.routes, path)

        if match is None:
            return await self.handle_404(request, send)

        new_scope = dict(scope, url_route={"kwargs": match.groupdict()})
        request.scope = new_scope
        try:
---
/Users/simon/Dropbox/dev/datasette/datasette/actor_auth_cookie.py:11:    if "ds_actor" not in request.cookies:
/Users/simon/Dropbox/dev/datasette/datasette/actor_auth_cookie.py:14:        decoded = datasette.unsign(request.cookies["ds_actor"], "actor")
/Users/simon/Dropbox/dev/datasette/datasette/cli.py:689:            cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
/Users/simon/Dropbox/dev/datasette/datasette/app.py:2415:            and "ds_actor" in request.cookies
/Users/simon/Dropbox/dev/datasette/datasette/app.py:2451:        response.set_cookie("ds_actor", self.sign(data, "actor"))
/Users/simon/Dropbox/dev/datasette/datasette/app.py:2454:        response.set_cookie("ds_actor", "", expires=0, max_age=0)
/Users/simon/Dropbox/dev/datasette/datasette/app.py:3201:        """If ``actor=`` was supplied, convert it into a signed ds_actor cookie."""
/Users/simon/Dropbox/dev/datasette/datasette/app.py:3206:        if "ds_actor" in cookies:
/Users/simon/Dropbox/dev/datasette/datasette/app.py:3207:            raise TypeError("Cannot pass both actor= and a ds_actor cookie")
/Users/simon/Dropbox/dev/datasette/datasette/app.py:3208:        cookies["ds_actor"] = self.actor_cookie(actor)
🧠 Thinking

Let me check lines 2415–2460 of app.py (the root's sign-in/sign-out endpoints). Also, to explain how permissions work together with the actor, I'll take a quick look at allowed() in permissions.py too. Furthermore, I'll also check tokens.py for API token authentication.

🔧 bash

{
  "command": "sed -n 2390,2460p /Users/simon/Dropbox/dev/datasette/datasette/app.py; echo ---TOKENS---; sed -n '1,80p' /Users/simon/Dropbox/dev/datasette/datasette/tokens.py"
}

↩️ bash result

            extra_template_vars.update(extra_vars)

        async def menu_links():
            links = []
            for hook in pm.hook.menu_links(
                datasette=self,
                actor=request.actor if request else None,
                request=request or None,
            ):
                extra_links = await await_me_maybe(hook)
                if extra_links:
                    links.extend(extra_links)
            return links

        # Keys added here must be documented in TEMPLATE_BASE_CONTEXT -
        # the contract tests fail otherwise
        template_context = {
            **context,
            "request": request,
            "crumb_items": self._crumb_items,
            "urls": self.urls,
            "actor": request.actor if request else None,
            "menu_links": menu_links,
            "display_actor": display_actor,
            "show_logout": request is not None
            and "ds_actor" in request.cookies
            and request.actor,
            "zip": zip,
            "body_scripts": body_scripts,
            "format_bytes": format_bytes,
            "show_messages": lambda: self._show_messages(request),
            "extra_css_urls": await self._asset_urls(
                "extra_css_urls", template, context, request, view_name
            ),
            "extra_js_urls": await self._asset_urls(
                "extra_js_urls", template, context, request, view_name
            ),
            "base_url": self.setting("base_url"),
            "datasette_version": __version__,
            **extra_template_vars,
        }
        if request and request.args.get("_context") and self.setting("template_debug"):
            return "<pre>{}</pre>".format(
                escape(
                    json.dumps(
                        template_context,
                        default=_template_context_json_default,
                        indent=4,
                    )
                )
            )

        return await template.render_async(template_context)

    def set_actor_cookie(
        self, response: Response, actor: dict, expire_after: int | None = None
    ):
        data = {"a": actor}
        if expire_after:
            expires_at = int(time.time()) + (24 * 60 * 60)
            data["e"] = baseconv.base62.encode(expires_at)
        response.set_cookie("ds_actor", self.sign(data, "actor"))

    def delete_actor_cookie(self, response: Response):
        response.set_cookie("ds_actor", "", expires=0, max_age=0)

    async def _asset_urls(self, key, template, context, request, view_name):
        # Flatten list-of-lists from plugins:
        seen_urls = set()
        collected = []
        for hook in getattr(pm.hook, key)(
---TOKENS---
"""
Token handler system for Datasette.

Provides a base class for token handlers and the default signed token handler.
Plugins can implement register_token_handler to provide custom token backends
(e.g. database-backed tokens that can be revoked and audited).
"""

from __future__ import annotations

import dataclasses
import time
from typing import TYPE_CHECKING

import itsdangerous

if TYPE_CHECKING:
    from datasette.app import Datasette


class TokenInvalid(Exception):
    """
    Raised by a TokenHandler when a token it recognizes is invalid -
    for example a bad signature, malformed payload or expired token.

    Datasette responds to this with an HTTP 401 error. Handlers should
    return None instead for tokens they do not recognize at all, so that
    other registered handlers get a chance to verify them.
    """

    def __init__(self, message="Invalid token"):
        self.message = message
        super().__init__(message)


@dataclasses.dataclass
class TokenRestrictions:
    """
    Restrictions to apply to a token, limiting which actions it can perform.

    Use the builder methods to construct restrictions::

        restrictions = (TokenRestrictions()
            .allow_all("view-instance")
            .allow_database("mydb", "create-table")
            .allow_resource("mydb", "mytable", "insert-row"))
    """

    all: list[str] = dataclasses.field(default_factory=list)
    database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
    resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)

    def allow_all(self, action: str) -> TokenRestrictions:
        """Allow an action across all databases and resources."""
        self.all.append(action)
        return self

    def allow_database(self, database: str, action: str) -> TokenRestrictions:
        """Allow an action on a specific database."""
        self.database.setdefault(database, []).append(action)
        return self

    def allow_resource(
        self, database: str, resource: str, action: str
    ) -> TokenRestrictions:
        """Allow an action on a specific resource within a database."""
        self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
        return self

    def abbreviated(self, datasette: Datasette) -> dict | None:
        """
        Return the abbreviated ``_r`` dictionary shape for this set of
        restrictions, using action abbreviations registered with ``datasette``.
        Returns ``None`` if no restrictions are set.
        """
        if not (self.all or self.database or self.resource):
            return None

        def abbreviate_action(action):
            action_obj = datasette.actions.get(action)
🧠 Thinking

This should be enough for a solid overview. Let me also check where the X-Auth-Token header gets processed — probably in a plugin (datasette-auth-tokens?) or somewhere within core. I'll grep for "X-Auth-Token".

🔧 bash

{
  "command": "grep -rn \"X-Auth-Token\" /Users/simon/Dropbox/dev/datasette --include=*.py | head; echo ---; sed -n '1,50p' /Users/simon/Dropbox/dev/datasette/datasette/permissions.py; echo ---; grep -n \"def allowed\\|async def allowed\" /Users/simon/Dropbox/dev/datasette/datasette/app.py"
}

↩️ bash result

---
import contextvars
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, NamedTuple

# Context variable to track when permission checks should be skipped
_skip_permission_checks = contextvars.ContextVar(
    "skip_permission_checks", default=False
)

# Request-scoped cache of permission check results. The ASGI router sets
# this to a fresh dict at the start of each request, so cached verdicts
# never outlive a request or leak between actors. Keys are
# (actor_json, action, parent, child) tuples, values are booleans.
_permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
    "permission_check_cache", default=None
)


class SkipPermissions:
    """Context manager to temporarily skip permission checks.

    This is not a stable API and may change in future releases.

    Usage:
        with SkipPermissions():
            # Permission checks are skipped within this block
            response = await datasette.client.get("/protected")
    """

    def __enter__(self):
        self.token = _skip_permission_checks.set(True)
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        _skip_permission_checks.reset(self.token)
        return False


class Resource(ABC):
    """
    Base class for all resource types.

    Each subclass represents a type of resource (e.g., TableResource, DatabaseResource).
    The class itself carries metadata about the resource type.
    Instances represent specific resources.
    """

    # Class-level metadata (subclasses must define these)
    name: str = None  # e.g., "table", "database", "model"
---
1695:    async def allowed_resources_sql(
1735:    async def allowed_resources(
1881:    async def allowed(
1912:    async def allowed_many(
🧠 Thinking

X-Auth-Token doesn't exist in the core — it's handled by a plugin (the datasette-auth-tokens plugin). The core provides verify_token via a token handler.

To complete the full picture, let me take a quick look at allowed() around line 1881 of app.py and how plugins check permissions (check_permission).

🔧 bash

{
  "command": "sed -n '1881,1960p' /Users/simon/Dropbox/dev/datasette/datasette/app.py; echo ---; grep -n \"def check_permission\" /Users/simon/Dropbox/dev/datasette/datasette/plugins.py | head"
}

↩️ bash result

    async def allowed(
        self,
        *,
        action: str,
        resource: Resource = None,
        actor: dict | None = None,
    ) -> bool:
        """
        Check if actor can perform action on specific resource.

        Uses SQL to check permission for a single resource without fetching all resources.
        This is efficient - it does NOT call allowed_resources() and check membership.

        For global actions, resource should be None (or omitted).

        Example:
            from datasette.resources import TableResource
            can_view = await datasette.allowed(
                action="view-table",
                resource=TableResource(database="analytics", table="users"),
                actor=actor
            )

            # For global actions, resource can be omitted:
            can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
        """
        results = await self.allowed_many(
            actions=[action], resource=resource, actor=actor
        )
        return results[action]

    async def allowed_many(
        self,
        *,
        actions: Sequence[str],
        resource: Resource = None,
        actor: dict | None = None,
    ) -> dict[str, bool]:
        """
        Check several actions against one resource for one actor.

        Resolves every action (plus any also_requires dependencies) with a
        single internal database query, instead of one or two queries per
        action. Results are stored in the request-scoped permission cache,
        so subsequent datasette.allowed() calls for the same checks within
        the same request are served from the cache.

        Example:
            from datasette.resources import TableResource
            results = await datasette.allowed_many(
                actions=["edit-schema", "drop-table", "insert-row"],
                resource=TableResource(database="data", table="exercise"),
                actor=actor,
            )
            # {"edit-schema": True, "drop-table": True, "insert-row": False}
        """
        from datasette.permissions import (
            _permission_check_cache,
            _skip_permission_checks,
        )
        from datasette.utils.actions_sql import check_permissions_for_actions

        # For global actions, resource is None
        parent = resource.parent if resource else None
        child = resource.child if resource else None

        # Expand also_requires dependencies (transitively) so that each
        # dependency is resolved within the same batch
        expanded = []

        def add_action(name):
            if name in expanded:
                return
            action_obj = self.actions.get(name)
            if action_obj is None:
                raise ValueError(f"Unknown action: {name}")
            expanded.append(name)
            if action_obj.also_requires:
                add_action(action_obj.also_requires)

---
🧠 Thinking

Got the full picture. Let me write a concise explanation of how authentication works in Datasette, based on this codebase. Key points:

  1. No built-in user accounts — Datasette core is agnostic about authentication; it's delegated to plugins via hooks. The only built-in login mechanism is the --root local development actor (with a signed cookie).

  2. The Actor model: Every request has an actor, which is either None or a dict containing at least "id". Available as request.actor. Display names are resolved from display/name/username/login/id (display_actor).

  3. Request pipeline (around line 2876 in app.py): For each incoming request, it calls the actor_from_request plugin hook (a pluggy hook). The first non-None result wins; if a token handler raises TokenInvalid, that becomes a 401 even if other credentials are valid.

  4. Cookie-based authentication (actor_auth_cookie.py): The built-in ds_actor cookie mechanism. Plugins call response.set_cookie("ds_actor", datasette.sign({"a": actor, "e": expiry}, "actor")) — in fact the core provides set_actor_cookie() (around line 2451 in app.py), which signs with itsdangerous's URLSafeSerializer using datasette._secret. On request, actor_from_request verifies it (unsign, checks for the presence of an "a" key, and base62-decodes to check the "e" expiry). BadSignature → None.

  5. Secret: self._secret — let me quickly confirm where this is set… probably derived from a random value or a config setting at startup. Worth mentioning: sign/unsign uses itsdangerous with that secret and per-namespace keys ("actor", "messages").

  6. API tokens (tokens.py): A token handler system — plugins register via the register_token_handler hook; core provides create_token()/verify_token(). Tokens are presented by clients (e.g., X-Auth-Token header in the datasette-auth-tokens plugin), and verify returns an actor dict. TokenInvalid → 401. Supports restrictions (TokenRestrictions).

  7. Permissions: Actor + action/resource; datasette.allowed(action=..., resource=..., actor=...) checks rules registered by plugins via check_permission hooks and YAML config, stored in the internal DB and evaluated with SQL (allowed_many). Root actor: global allow-all when --root. Per-request caching.

  8. Login/logout: Plugins implement login pages; core has a /-/logout route that clears ds_actor (delete_actor_cookie) — let me quickly verify… line 2454 in app.py is delete_actor_cookie. There's probably a logout view around special.py or in the routes. Not important, but I can mention it.

To be accurate, let me quickly check where _secret comes from and whether there's a built-in /-/login for root.

🔧 bash

{
  "command": "grep -n \"_secret\" /Users/simon/Dropbox/dev/datasette/datasette/app.py | head; grep -rn \"logout\\|root_enabled\" /Users/simon/Dropbox/dev/datasette/datasette/app.py /Users/simon/Dropbox/dev/datasette/datasette/cli.py | grep -in \"route\\|path\\|--root\\|secret =\" | head"
}

↩️ bash result

76:    resolve_env_secrets,
431:        self._secret = secret or secrets.token_hex(32)
831:        return URLSafeSerializer(self._secret, namespace).dumps(value)
834:        return URLSafeSerializer(self._secret, namespace).loads(signed)
1447:        return resolve_env_secrets(config, os.environ)
🧠 Thinking

Alright — _secret is either passed in or randomly generated per process (so by default cookies are tied to the process lifetime unless a secret is provided).

That's enough info now. I'll write up the summary concisely with file references attached.

Here's how auth works in this codebase:

Core idea: "actors" + plugins, no built-in user accounts

Datasette core doesn't do authentication itself — it defines a neutral concept of an actor (a dict, must have "id"), and delegates who the actor is to plugins. Authentication = producing that dict per request; authorization = checking permissions for it.

1. Per-request actor resolution (datasette/app.py ~line 2876)

For every incoming ASGI request, Datasette fires the actor_from_request plugin hook:

results = pm.hook.actor_from_request(datasette=self.ds, request=request)
# first non-None result wins → scope["actor"] → request.actor

If a token handler raises TokenInvalid, that's an immediate 401 even if other credentials would have worked. Unauthenticated requests get actor=None.

2. Cookie auth — the ds_actor cookie (datasette/actor_auth_cookie.py)

The core ships one built-in credential: a signed session cookie.

  • Setting: plugins call set_actor_cookie(response, actor) (app.py ~2451), which does response.set_cookie("ds_actor", self.sign({"a": actor, "e": expiry}, "actor"))
  • Signing: itsdangerous URLSafeSerializer(self._secret, namespace)_secret is a random 32-byte hex per process unless supplied (app.py:431). Namespaces like "actor"/"messages" keep cookie types separate.
  • Verifying: the built-in hook impl unsigns the cookie, checks for an "a" key, and decodes the base62-encoded "e" expiry timestamp. BadSignature → anonymous (None).

So plugins like datasette-auth-cookies / -auth-github implement a login page that sets this cookie; the core handles the rest, including /-/logout (which just clears it via delete_actor_cookie).

3. API tokens (datasette/tokens.py)

A token handler system: plugins register handlers via register_token_handler; the core provides Datasette.create_token() / verify_token() which try each registered handler in order and return an actor dict for the first that recognizes the token. A client presents a token (e.g. datasette-auth-tokens reads the X-Auth-Token header) inside its own actor_from_request impl, calling verify_token(). Tokens can carry TokenRestrictions limiting which actions/databases/tables they may use.

4. The root actor (--root)

The only "built-in" login: with --root, an actor of {"id": "root"} gets a global allow-all rule for every action (including writes and debug), still overridable by explicit deny rules in datasette.yaml. It's meant for local dev.

5. Permissions (separate from auth)

Once you have an actor, checks go through datasette.allowed(action=..., resource=..., actor=...)allowed_many() (app.py ~1881). Rules come from plugins' check_permission hooks and YAML config, are stored in the internal SQLite DB, resolved with a single SQL query per batch, cached per request via a contextvar (permissions.py). Actor display names resolve from display/name/username/login/id.

In short: plugins decide who (via actor_from_request, using signed cookies or tokens); Datasette core decides what they can do (permission rules + allowed()), with the actor dict flowing through everything as request.actor. Full docs in docs/authentication.rst.

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