- Session ID: 01a006d4-fa13-76b4-83f3-1a0ab01392c5
- Model:
spark/qwen3.8-27b - Working dir:
/Users/simon/Dropbox/dev/datasette - Thinking level:
low
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:
-
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
--rootlocal development actor (with a signed cookie). -
The Actor model: Every request has an
actor, which is eitherNoneor a dict containing at least"id". Available asrequest.actor. Display names are resolved from display/name/username/login/id (display_actor). -
Request pipeline (around line 2876 in app.py): For each incoming request, it calls the
actor_from_requestplugin hook (a pluggy hook). The first non-None result wins; if a token handler raisesTokenInvalid, that becomes a 401 even if other credentials are valid. -
Cookie-based authentication (
actor_auth_cookie.py): The built-inds_actorcookie mechanism. Plugins callresponse.set_cookie("ds_actor", datasette.sign({"a": actor, "e": expiry}, "actor"))— in fact the core providesset_actor_cookie()(around line 2451 in app.py), which signs with itsdangerous's URLSafeSerializer usingdatasette._secret. On request,actor_from_requestverifies it (unsign, checks for the presence of an"a"key, and base62-decodes to check the"e"expiry). BadSignature → None. -
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"). -
API tokens (
tokens.py): A token handler system — plugins register via theregister_token_handlerhook; core providescreate_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). -
Permissions: Actor + action/resource;
datasette.allowed(action=..., resource=..., actor=...)checks rules registered by plugins viacheck_permissionhooks and YAML config, stored in the internal DB and evaluated with SQL (allowed_many). Root actor: global allow-all when--root. Per-request caching. -
Login/logout: Plugins implement login pages; core has a
/-/logoutroute 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:
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.
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.actorIf a token handler raises TokenInvalid, that's an immediate 401 even if other credentials would have worked. Unauthenticated requests get actor=None.
The core ships one built-in credential: a signed session cookie.
- Setting: plugins call
set_actor_cookie(response, actor)(app.py ~2451), which doesresponse.set_cookie("ds_actor", self.sign({"a": actor, "e": expiry}, "actor")) - Signing: itsdangerous
URLSafeSerializer(self._secret, namespace)—_secretis 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).
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.
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.
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.