I generated this document by running the following in the root of https://github.com/simonw/datasette
files-to-prompt datasette -e py -c | \
llm -m o3-mini -s 'write extensive documentation for how the permissions system works, as markdown'
The -e py
command gathers all of the *.py
files, but ignores *.md
or *.html
.
The datasette/
folder this targets does not contain the tests - those live in tests/
.
The -c
option means "Claude-style XML".
Here's the output of that files-to-prompt
command: https://gist.github.com/simonw/1922544763b08c76f0b904e2ece364ea
llm logs -u
shows that it used 99,348 input tokens, 3,118 output tokens. That's a cost of 12.3 cents.
(o3-mini has an input limit of 200,000 tokens, so we only used half of that.)
Here's what I got:
This document describes in detail how Datasette’s permissions system works, how permissions are registered and checked, and how customizations (both via configuration and plugins) are applied. The permissions system is central to Datasette’s security, controlling who may view databases, tables, execute SQL queries, modify data, and perform other actions.
Datasette uses a flexible and extensible permissions system based on Pluggy hooks. The system is built around two core ideas:
- Permission Objects: These objects encapsulate attributes related to each permission (such as its name, abbreviation, description, and whether it applies to an instance, database, or resource).
- Permission Checks via Hooks: When a permission needs to be verified, Datasette calls a chain of hook implementations (using the
permission_allowed
hook) provided by the defaults as well as any custom plugins. Each hook returns an opinion (typicallyTrue
,False
, orNone
to indicate no opinion).
The final decision is determined by applying a veto rule—if any hook returns a denial (False
), the permission is denied; if one or more return approval and no denial is present, then the permission is granted; if no plugin provides any direct opinion, Datasette falls back on the default value defined on the registered permission.
Datasette’s built-in permissions are registered via the register_permissions
hook. In particular, the module datasette/default_permissions.py
returns a tuple of pre-defined Permission
objects. An example of one such object is:
Permission(
name="view-instance",
abbr="vi",
description="View Datasette instance",
takes_database=False,
takes_resource=False,
default=True,
)
Each registered permission has several attributes:
-
name:
A unique string identifier for the permission. For example,"view-instance"
,"insert-row"
,"drop-table"
, etc. -
abbr:
A shorter abbreviation for the permission (e.g."vi"
for"view-instance"
). This abbreviation may be used in configuration or token restrictions. -
description:
A human-readable string describing what the permission controls. -
takes_database & takes_resource:
Boolean flags indicating whether the permission is context-sensitive.- If
takes_database
isTrue
, then the permission applies in the scope of a database. - If
takes_resource
isTrue
, it applies even more specifically (for example, to a table or row).
- If
-
default:
The default state of the permission (usuallyTrue
orFalse
). This value is used if no overriding configuration or plugin opinion is provided. -
implies_can_view (internal):
An internal flag used to mark permissions that imply the ability to view a database, table, or instance. This flag is used by default permission logic when evaluating view-related requests.
Plugins can register their own permissions using the same register_permissions
hook. When the Datasette startup process calls all implementations registered on that hook, it collects all Permission
objects provided by core and external plugins. The datasette instance then maintains an internal mapping of permission names (and abbreviations) to these Permission objects.
A duplicate permission name or abbreviation (with different Permission objects) will cause Datasette to signal a startup error. This ensures that every permission is uniquely defined.
When Datasette needs to verify if an actor (a user or other requester) has a given permission, it calls the hook permission_allowed
. This hook can be implemented by both the default implementation and any additional plugins.
-
Default Opinion:
The default implementation (seedatasette/default_permissions.py
in the functionpermission_allowed_default
) contains logic that:- Grants unrestricted access to privileged users (e.g. an actor with ID
"root"
) for several sensitive operations. - For view-related permissions (like
"view-instance"
,"view-database"
,"view-table"
) it optionally defers to custom configuration blocks. - For actions like executing SQL, additional fallback rules are applied. For example, if the Datasette configuration disallows arbitrary SQL execution (the
"default_allow_sql"
setting is off), the permission check returnsFalse
.
- Grants unrestricted access to privileged users (e.g. an actor with ID
-
Custom “Allow” Blocks in Metadata/Configuration:
Datasette allows administrators to further refine permission decisions by configuring allow blocks in the metadata (typically indatasette.json
or YAML configuration). These blocks can be applied at different levels:- Global / Instance Level: An
"allow"
block provided at the top level of configuration governs"view-instance"
for example. - Database Level: Under a specific database’s configuration (
databases.<db_name>.allow
), you can restrict or permit viewing the database. - Table or Resource Level: Within the configuration for a table (
databases.<db_name>.tables.<table_name>.allow
) or a canned query (databases.<db_name>.queries.<query_name>.allow
), you can control access on a more granular level.
When a user performs an action, Datasette examines the configuration hierarchy from top-level to database and then to table/query level. The helper function (see
_resolve_config_view_permissions
and_resolve_config_permissions_blocks
in the default permissions module) uses the helper functionactor_matches_allow
to compare the actor’s attributes with the allow rules defined in the configuration. - Global / Instance Level: An
-
Actor Restrictions from Token Authentication:
In addition to configuration-based rules, Datasette supports issuing signed API tokens. Tokens may carry restrictions in a structured form under the_r
key. The hookpermission_allowed_actor_restrictions
inspects the token’s embedded restrictions.- If an actor’s token has an
_r
dict defining specific allowed actions for all databases, a particular database, or even individual table resources, then during a permission check Datasette calls the helperrestrictions_allow_action
to decide if the requested action is permitted. - Notice that these token-based restrictions are restrictive—if the actor’s token prohibits a particular permission, the check returns
False
.
- If an actor’s token has an
-
Gathering Opinions:
Datasette consults every registered plugin for its input on a permission check. The results (opinions) are collected as a list. The logic then applies the following rules:- Veto Rule: If at least one opinion is
False
, the final result is denied. - Grant Rule: Otherwise, if at least one opinion is
True
, then the permission is granted. - Default Fallback: If no plugin provides any opinion (i.e. all returned
None
), then the system falls back on the default provided by the corresponding Permission object (thedefault
flag).
- Veto Rule: If at least one opinion is
Inside Datasette’s request handling code (for example, in datasette/app.py
and various view modules), permission checks are performed like this:
allowed = await datasette.permission_allowed(actor=request.actor, action="view-database", resource=database)
if not allowed:
raise Forbidden("You do not have permission to view this database")
Additionally, helper functions such as ensure_permissions()
may be used when multiple permission checks must be satisfied; this function iterates over each required permission and raises a Forbidden
error if any check fails.
Plugin authors can implement the register_permissions
hook to contribute new permission objects. For example:
from datasette import hookimpl, Permission
@hookimpl
def register_permissions(datasette):
return [
Permission(
name="custom-action",
abbr="ca",
description="Allow custom action",
takes_database=True,
takes_resource=True,
default=False,
)
]
Each permission object contributed here will be merged with core permissions, and subsequently used in permission checks.
Plugins may also implement the permission_allowed
hook to control the logic that determines whether an actor has a particular permission. The default permissions system is implemented as a hook (with tryfirst=True
) to ensure that core behavior is applied before any other opinions. Custom implementations can be provided to override or extend default behavior.
For example, a plugin might include:
from datasette import hookimpl
@hookimpl
def permission_allowed(datasette, actor, action, resource):
# Custom logic: deny a specific action for all actors except one.
if action == "delete-row" and (actor is None or actor.get("id") != "admin"):
return False
# Otherwise, don't provide an opinion.
return None
This hook (implemented in the core default permissions module) examines an actor’s signed token for restrictions. It ensures that if a token has a restrictions block (under the _r
key), then every permission check considers those restrictions, and if the restrictions do not explicitly allow an action the check returns False
.
Datasette’s permissions system works in tandem with its actor authentication mechanism. The actor is typically stored in a signed cookie (or provided via an Authorization header when using an API token). The token creation code (see datasette/app.py
and the CLI command in datasette/cli.py
) uses the create_token()
method of the Datasette class.
When creating a token, you may pass in parameters to restrict the token’s use:
-
restrict_all:
A list of actions that are allowed (or restricted) globally. -
restrict_database:
A dictionary mapping database names to lists of actions. -
restrict_resource:
A dictionary mapping database names to, then, resource names (tables, queries) and lists of actions.
When a token is used to authenticate a request, the hook actor_from_request
in datasette/default_permissions.py
(or in related actor authentication modules) unsigns the token and extracts the actor information. Later, during permission checks, the permission_allowed_actor_restrictions
hook compares these restrictions with the permissions being requested.
Datasette allows administrators to customize permission behavior via metadata configuration. In the configuration file (commonly datasette.json
or YAML), you can specify:
-
Top-Level Allow Blocks:
Using an"allow"
key at the top level to control instance-wide permissions (e.g."allow": {"view-instance": "*"}
). -
Database-Level Permissions:
Underdatabases.<db_name>
, you can specify"allow"
(or"allow_sql"
) to restrict queries or viewing on a per-database basis. -
Table-Level Permissions:
Underdatabases.<db_name>.tables.<table>
, you can provide an"allow"
block that limits actions on that table.
For example:{ "databases": { "sample.db": { "tables": { "secrets": { "allow": { "view-table": false } } } } } }
Such configuration blocks override the default permission values when the system uses the helper function actor_matches_allow()
during permission checks.
Additionally, there is a migration mechanism in Datasette (see datasette/utils/internal_db.py
and datasette/utils/resolve_env_secrets
) that can move certain configuration keys (for example, plugin configurations or table configuration options) from metadata to a persistent internal database. This allows administrators to dynamically change permissions without restarting Datasette.
Datasette includes several debug views which give administrators insight into the permissions system. For example:
-
Permissions Debug View (
-/permissions
):
Displays a history of recent permission checks including:- The actor
- Action being checked
- Resource (if applicable)
- Whether the default permission value was used
- The final result (allowed/denied)
-
Allow Debug View (
-/allow-debug
):
Offers a playground where you can input an actor’s JSON data and a custom"allow"
block to see whether the actor would be permitted a given action.
Reading these debug pages can help administrators and plugin authors understand how permissions decisions are being derived in practice.
In summary, Datasette’s permissions system:
- Is built around
Permission
objects which describe what each permission means. - Uses a chain-of-responsibility pattern via Pluggy hooks (such as
permission_allowed
andpermission_allowed_actor_restrictions
) to gather opinions on a permission check. - Respects configuration-based allow blocks supplied by administrators at instance, database, and resource levels.
- Supports token-based authentication where tokens can impose additional restrictions.
- Defaults to the preconfigured value if no explicit plugin opinion is provided.
This flexible design allows Datasette to be secured out-of-the-box while also being highly customizable by administrators and plugin authors.
For more details, refer to the Datasette source files (such as datasette/default_permissions.py
and datasette/permissions.py
) where the implementation of permission registration and checks is provided.
I created this using: