Add parent filter and include_is_private to allowed_resources()

Major improvements to the allowed_resources() API:

1. **parent filter**: Filter results to specific database in SQL, not Python
   - Avoids loading thousands of tables into Python memory
   - Filtering happens efficiently in SQLite

2. **include_is_private flag**: Detect private resources in single SQL query
   - Compares actor permissions vs anonymous permissions in SQL
   - LEFT JOIN between actor_allowed and anon_allowed CTEs
   - Returns is_private column: 1 if anonymous blocked, 0 otherwise
   - No individual check_visibility() calls needed

3. **Resource.private property**: Safe access with clear error messages
   - Raises AttributeError if accessed without include_is_private=True
   - Prevents accidental misuse of the property

4. **Database view optimization**: Use new API to eliminate redundant checks
   - Single bulk query replaces N individual permission checks
   - Private flag computed in SQL, not via check_visibility() calls
   - Views filtered from allowed_dict instead of checking db.view_names()

All permission filtering now happens in SQLite where it belongs, with
minimal data transferred to Python.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2025-10-24 09:30:37 -07:00
commit 3adddad6aa
4 changed files with 319 additions and 133 deletions

View file

@ -1305,15 +1305,28 @@ class Datasette:
*, *,
action: str, action: str,
actor: dict | None = None, actor: dict | None = None,
parent: str | None = None,
include_is_private: bool = False,
) -> tuple[str, dict]: ) -> tuple[str, dict]:
""" """
Build SQL query to get all resources the actor can access for the given action. Build SQL query to get all resources the actor can access for the given action.
Args:
action: The action name (e.g., "view-table")
actor: The actor dict (or None for unauthenticated)
parent: Optional parent filter (e.g., database name) to limit results
include_is_private: If True, include is_private column showing if anonymous cannot access
Returns a tuple of (query, params) that can be executed against the internal database. Returns a tuple of (query, params) that can be executed against the internal database.
The query returns rows with (parent, child, reason) columns. The query returns rows with (parent, child, reason) columns, plus is_private if requested.
Example: Example:
query, params = await datasette.allowed_resources_sql(action="view-table", actor=actor) query, params = await datasette.allowed_resources_sql(
action="view-table",
actor=actor,
parent="mydb",
include_is_private=True
)
result = await datasette.get_internal_database().execute(query, params) result = await datasette.get_internal_database().execute(query, params)
""" """
from datasette.utils.actions_sql import build_allowed_resources_sql from datasette.utils.actions_sql import build_allowed_resources_sql
@ -1322,12 +1335,17 @@ class Datasette:
if not action_obj: if not action_obj:
raise ValueError(f"Unknown action: {action}") raise ValueError(f"Unknown action: {action}")
return await build_allowed_resources_sql(self, actor, action) return await build_allowed_resources_sql(
self, actor, action, parent=parent, include_is_private=include_is_private
)
async def allowed_resources( async def allowed_resources(
self, self,
action: str, action: str,
actor: dict | None = None, actor: dict | None = None,
*,
parent: str | None = None,
include_is_private: bool = False,
) -> list["Resource"]: ) -> list["Resource"]:
""" """
Return all resources the actor can access for the given action. Return all resources the actor can access for the given action.
@ -1335,10 +1353,25 @@ class Datasette:
Uses SQL to filter resources based on cascading permission rules. Uses SQL to filter resources based on cascading permission rules.
Returns instances of the appropriate Resource subclass. Returns instances of the appropriate Resource subclass.
Args:
action: The action name (e.g., "view-table")
actor: The actor dict (or None for unauthenticated)
parent: Optional parent filter (e.g., database name) to limit results
include_is_private: If True, adds a .private attribute to each Resource
Example: Example:
# Get all tables
tables = await datasette.allowed_resources("view-table", actor) tables = await datasette.allowed_resources("view-table", actor)
for table in tables: for table in tables:
print(f"{table.parent}/{table.child}") print(f"{table.parent}/{table.child}")
# Get tables for specific database with private flag
tables = await datasette.allowed_resources(
"view-table", actor, parent="mydb", include_is_private=True
)
for table in tables:
if table.private:
print(f"{table.child} is private")
""" """
from datasette.permissions import Resource from datasette.permissions import Resource
@ -1346,17 +1379,24 @@ class Datasette:
if not action_obj: if not action_obj:
raise ValueError(f"Unknown action: {action}") raise ValueError(f"Unknown action: {action}")
query, params = await self.allowed_resources_sql(action=action, actor=actor) query, params = await self.allowed_resources_sql(
action=action,
actor=actor,
parent=parent,
include_is_private=include_is_private,
)
result = await self.get_internal_database().execute(query, params) result = await self.get_internal_database().execute(query, params)
# Instantiate the appropriate Resource subclass for each row # Instantiate the appropriate Resource subclass for each row
resource_class = action_obj.resource_class resource_class = action_obj.resource_class
resources = [] resources = []
for row in result.rows: for row in result.rows:
# row[0]=parent, row[1]=child, row[2]=reason (ignored) # row[0]=parent, row[1]=child, row[2]=reason (ignored), row[3]=is_private (if requested)
# Create instance directly with parent/child from base class # Create instance directly with parent/child from base class
resource = object.__new__(resource_class) resource = object.__new__(resource_class)
Resource.__init__(resource, parent=row[0], child=row[1]) Resource.__init__(resource, parent=row[0], child=row[1])
if include_is_private:
resource.private = bool(row[3])
resources.append(resource) resources.append(resource)
return resources return resources

View file

@ -26,6 +26,29 @@ class Resource(ABC):
""" """
self.parent = parent self.parent = parent
self.child = child self.child = child
self._private = None # Sentinel to track if private was set
@property
def private(self) -> bool:
"""
Whether this resource is private (accessible to actor but not anonymous).
This property is only available on Resource objects returned from
allowed_resources() when include_is_private=True is used.
Raises:
AttributeError: If accessed without calling include_is_private=True
"""
if self._private is None:
raise AttributeError(
"The 'private' attribute is only available when using "
"allowed_resources(..., include_is_private=True)"
)
return self._private
@private.setter
def private(self, value: bool):
self._private = value
@classmethod @classmethod
@abstractmethod @abstractmethod

View file

@ -63,6 +63,9 @@ async def build_allowed_resources_sql(
datasette: "Datasette", datasette: "Datasette",
actor: dict | None, actor: dict | None,
action: str, action: str,
*,
parent: str | None = None,
include_is_private: bool = False,
) -> tuple[str, dict]: ) -> tuple[str, dict]:
""" """
Build a SQL query that returns all resources the actor can access for this action. Build a SQL query that returns all resources the actor can access for this action.
@ -71,14 +74,17 @@ async def build_allowed_resources_sql(
datasette: The Datasette instance datasette: The Datasette instance
actor: The actor dict (or None for unauthenticated) actor: The actor dict (or None for unauthenticated)
action: The action name (e.g., "view-table", "view-database") action: The action name (e.g., "view-table", "view-database")
parent: Optional parent filter to limit results (e.g., database name)
include_is_private: If True, add is_private column showing if anonymous cannot access
Returns: Returns:
A tuple of (sql_query, params_dict) A tuple of (sql_query, params_dict)
The returned SQL query will have three columns: The returned SQL query will have three columns (or four with include_is_private):
- parent: The parent resource identifier (or NULL) - parent: The parent resource identifier (or NULL)
- child: The child resource identifier (or NULL) - child: The child resource identifier (or NULL)
- reason: The reason from the rule that granted access - reason: The reason from the rule that granted access
- is_private: (if include_is_private) 1 if anonymous cannot access, 0 otherwise
Example: Example:
For action="view-table", this might return: For action="view-table", this might return:
@ -116,90 +122,219 @@ async def build_allowed_resources_sql(
# If no rules, return empty result (deny all) # If no rules, return empty result (deny all)
if not rule_sqls: if not rule_sqls:
return "SELECT NULL AS parent, NULL AS child, NULL AS reason WHERE 0", {} empty_cols = "NULL AS parent, NULL AS child, NULL AS reason"
if include_is_private:
empty_cols += ", NULL AS is_private"
return f"SELECT {empty_cols} WHERE 0", {}
# Build the cascading permission query # Build the cascading permission query
rules_union = " UNION ALL ".join(rule_sqls) rules_union = " UNION ALL ".join(rule_sqls)
query = f""" # Build the main query
WITH query_parts = [
base AS ( "WITH",
{base_resources_sql} "base AS (",
), f" {base_resources_sql}",
all_rules AS ( "),",
{rules_union} "all_rules AS (",
), f" {rules_union}",
child_lvl AS ( "),",
SELECT b.parent, b.child, ]
MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,
MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow, # If include_is_private, we need to build anonymous permissions too
MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason, if include_is_private:
MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason # Get anonymous permission rules
FROM base b anon_rule_results = pm.hook.permission_resources_sql(
LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child datasette=datasette,
GROUP BY b.parent, b.child actor=None,
), action=action,
parent_lvl AS ( )
SELECT b.parent, b.child, anon_rule_sqls = []
MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny, anon_params = {}
MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow, for result in anon_rule_results:
MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason, result = await await_me_maybe(result)
MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason sqls, params = _process_permission_results(result)
FROM base b anon_rule_sqls.extend(sqls)
LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL # Namespace anonymous params to avoid conflicts
GROUP BY b.parent, b.child for key, value in params.items():
), anon_params[f"anon_{key}"] = value
global_lvl AS (
SELECT b.parent, b.child, # Rewrite anonymous SQL to use namespaced params
MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny, anon_sqls_rewritten = []
MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow, for sql in anon_rule_sqls:
MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason, for key in params.keys():
MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason sql = sql.replace(f":{key}", f":anon_{key}")
FROM base b anon_sqls_rewritten.append(sql)
LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL
GROUP BY b.parent, b.child all_params.update(anon_params)
),
decisions AS ( if anon_sqls_rewritten:
SELECT anon_rules_union = " UNION ALL ".join(anon_sqls_rewritten)
b.parent, b.child, query_parts.extend(
-- Cascading permission logic: child parent global, DENY beats ALLOW at each level [
-- Priority order: "anon_rules AS (",
-- 1. Child-level deny (most specific, blocks access) f" {anon_rules_union}",
-- 2. Child-level allow (most specific, grants access) "),",
-- 3. Parent-level deny (intermediate, blocks access) ]
-- 4. Parent-level allow (intermediate, grants access) )
-- 5. Global-level deny (least specific, blocks access)
-- 6. Global-level allow (least specific, grants access) # Continue with the cascading logic
-- 7. Default deny (no rules match) query_parts.extend(
CASE [
WHEN cl.any_deny = 1 THEN 0 "child_lvl AS (",
WHEN cl.any_allow = 1 THEN 1 " SELECT b.parent, b.child,",
WHEN pl.any_deny = 1 THEN 0 " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
WHEN pl.any_allow = 1 THEN 1 " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
WHEN gl.any_deny = 1 THEN 0 " MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,",
WHEN gl.any_allow = 1 THEN 1 " MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason",
ELSE 0 " FROM base b",
END AS is_allowed, " LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
CASE " GROUP BY b.parent, b.child",
WHEN cl.any_deny = 1 THEN cl.deny_reason "),",
WHEN cl.any_allow = 1 THEN cl.allow_reason "parent_lvl AS (",
WHEN pl.any_deny = 1 THEN pl.deny_reason " SELECT b.parent, b.child,",
WHEN pl.any_allow = 1 THEN pl.allow_reason " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
WHEN gl.any_deny = 1 THEN gl.deny_reason " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
WHEN gl.any_allow = 1 THEN gl.allow_reason " MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,",
ELSE 'default deny' " MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason",
END AS reason " FROM base b",
FROM base b " LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
JOIN child_lvl cl USING (parent, child) " GROUP BY b.parent, b.child",
JOIN parent_lvl pl USING (parent, child) "),",
JOIN global_lvl gl USING (parent, child) "global_lvl AS (",
) " SELECT b.parent, b.child,",
SELECT parent, child, reason " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
FROM decisions " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
WHERE is_allowed = 1 " MAX(CASE WHEN ar.allow = 0 THEN ar.reason ELSE NULL END) AS deny_reason,",
ORDER BY parent, child " MAX(CASE WHEN ar.allow = 1 THEN ar.reason ELSE NULL END) AS allow_reason",
""" " FROM base b",
return query.strip(), all_params " LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
]
)
# Add anonymous decision logic if needed
if include_is_private:
query_parts.extend(
[
"anon_child_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
" GROUP BY b.parent, b.child",
"),",
"anon_parent_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_decisions AS (",
" SELECT",
" b.parent, b.child,",
" CASE",
" WHEN acl.any_deny = 1 THEN 0",
" WHEN acl.any_allow = 1 THEN 1",
" WHEN apl.any_deny = 1 THEN 0",
" WHEN apl.any_allow = 1 THEN 1",
" WHEN agl.any_deny = 1 THEN 0",
" WHEN agl.any_allow = 1 THEN 1",
" ELSE 0",
" END AS anon_is_allowed",
" FROM base b",
" JOIN anon_child_lvl acl USING (parent, child)",
" JOIN anon_parent_lvl apl USING (parent, child)",
" JOIN anon_global_lvl agl USING (parent, child)",
"),",
]
)
# Final decisions
query_parts.extend(
[
"decisions AS (",
" SELECT",
" b.parent, b.child,",
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
" -- Priority order:",
" -- 1. Child-level deny (most specific, blocks access)",
" -- 2. Child-level allow (most specific, grants access)",
" -- 3. Parent-level deny (intermediate, blocks access)",
" -- 4. Parent-level allow (intermediate, grants access)",
" -- 5. Global-level deny (least specific, blocks access)",
" -- 6. Global-level allow (least specific, grants access)",
" -- 7. Default deny (no rules match)",
" CASE",
" WHEN cl.any_deny = 1 THEN 0",
" WHEN cl.any_allow = 1 THEN 1",
" WHEN pl.any_deny = 1 THEN 0",
" WHEN pl.any_allow = 1 THEN 1",
" WHEN gl.any_deny = 1 THEN 0",
" WHEN gl.any_allow = 1 THEN 1",
" ELSE 0",
" END AS is_allowed,",
" CASE",
" WHEN cl.any_deny = 1 THEN cl.deny_reason",
" WHEN cl.any_allow = 1 THEN cl.allow_reason",
" WHEN pl.any_deny = 1 THEN pl.deny_reason",
" WHEN pl.any_allow = 1 THEN pl.allow_reason",
" WHEN gl.any_deny = 1 THEN gl.deny_reason",
" WHEN gl.any_allow = 1 THEN gl.allow_reason",
" ELSE 'default deny'",
" END AS reason",
]
)
if include_is_private:
query_parts.append(
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
)
query_parts.extend(
[
" FROM base b",
" JOIN child_lvl cl USING (parent, child)",
" JOIN parent_lvl pl USING (parent, child)",
" JOIN global_lvl gl USING (parent, child)",
]
)
if include_is_private:
query_parts.append(" JOIN anon_decisions ad USING (parent, child)")
query_parts.append(")")
# Final SELECT
select_cols = "parent, child, reason"
if include_is_private:
select_cols += ", is_private"
query_parts.append(f"SELECT {select_cols}")
query_parts.append("FROM decisions")
query_parts.append("WHERE is_allowed = 1")
# Add parent filter if specified
if parent is not None:
query_parts.append(" AND parent = :filter_parent")
all_params["filter_parent"] = parent
query_parts.append("ORDER BY parent, child")
query = "\n".join(query_parts)
return query, all_params
async def check_permission_for_resource( async def check_permission_for_resource(

View file

@ -71,34 +71,24 @@ class DatabaseView(View):
metadata = await datasette.get_database_metadata(database) metadata = await datasette.get_database_metadata(database)
# Get all tables/views this actor can see in bulk # Get all tables/views this actor can see in bulk with private flag
from datasette.resources import TableResource from datasette.resources import TableResource
allowed_tables = await datasette.allowed_resources("view-table", request.actor) allowed_tables = await datasette.allowed_resources(
allowed_table_set = { "view-table", request.actor, parent=database, include_is_private=True
(r.parent, r.child) for r in allowed_tables if r.parent == database )
} # Create lookup dict for quick access
allowed_dict = {r.child: r for r in allowed_tables}
sql_views = [] # Filter to just views
for view_name in await db.view_names(): view_names_set = set(await db.view_names())
if (database, view_name) in allowed_table_set: sql_views = [
# Check if it's private (requires elevated permissions) {"name": name, "private": allowed_dict[name].private}
_, view_private = await datasette.check_visibility( for name in allowed_dict
request.actor, if name in view_names_set
permissions=[ ]
("view-table", (database, view_name)),
("view-database", database),
"view-instance",
],
)
sql_views.append(
{
"name": view_name,
"private": view_private,
}
)
tables = await get_tables(datasette, request, db, allowed_table_set) tables = await get_tables(datasette, request, db, allowed_dict)
canned_queries = [] canned_queries = []
for query in ( for query in (
await datasette.get_canned_queries(database, request.actor) await datasette.get_canned_queries(database, request.actor)
@ -341,7 +331,16 @@ class QueryContext(Context):
) )
async def get_tables(datasette, request, db, allowed_table_set): async def get_tables(datasette, request, db, allowed_dict):
"""
Get list of tables with metadata for the database view.
Args:
datasette: The Datasette instance
request: The current request
db: The database
allowed_dict: Dict mapping table name -> Resource object with .private attribute
"""
tables = [] tables = []
database = db.name database = db.name
table_counts = await db.table_counts(100) table_counts = await db.table_counts(100)
@ -349,19 +348,9 @@ async def get_tables(datasette, request, db, allowed_table_set):
all_foreign_keys = await db.get_all_foreign_keys() all_foreign_keys = await db.get_all_foreign_keys()
for table in table_counts: for table in table_counts:
if (database, table) not in allowed_table_set: if table not in allowed_dict:
continue continue
# Check if it's private (requires elevated permissions)
_, table_private = await datasette.check_visibility(
request.actor,
permissions=[
("view-table", (database, table)),
("view-database", database),
"view-instance",
],
)
table_columns = await db.table_columns(table) table_columns = await db.table_columns(table)
tables.append( tables.append(
{ {
@ -372,7 +361,7 @@ async def get_tables(datasette, request, db, allowed_table_set):
"hidden": table in hidden_table_names, "hidden": table in hidden_table_names,
"fts_table": await db.fts_table(table), "fts_table": await db.fts_table(table),
"foreign_keys": all_foreign_keys[table], "foreign_keys": all_foreign_keys[table],
"private": table_private, "private": allowed_dict[table].private,
} }
) )
tables.sort(key=lambda t: (t["hidden"], t["name"])) tables.sort(key=lambda t: (t["hidden"], t["name"]))
@ -521,13 +510,14 @@ class QueryView(View):
db = await datasette.resolve_database(request) db = await datasette.resolve_database(request)
database = db.name database = db.name
# Get all tables/views this actor can see in bulk # Get all tables/views this actor can see in bulk with private flag
from datasette.resources import TableResource from datasette.resources import TableResource
allowed_tables = await datasette.allowed_resources("view-table", request.actor) allowed_tables = await datasette.allowed_resources(
allowed_table_set = { "view-table", request.actor, parent=database, include_is_private=True
(r.parent, r.child) for r in allowed_tables if r.parent == database )
} # Create lookup dict for quick access
allowed_dict = {r.child: r for r in allowed_tables}
# Are we a canned query? # Are we a canned query?
canned_query = None canned_query = None
@ -828,9 +818,7 @@ class QueryView(View):
show_hide_text=show_hide_text, show_hide_text=show_hide_text,
editable=not canned_query, editable=not canned_query,
allow_execute_sql=allow_execute_sql, allow_execute_sql=allow_execute_sql,
tables=await get_tables( tables=await get_tables(datasette, request, db, allowed_dict),
datasette, request, db, allowed_table_set
),
named_parameter_values=named_parameter_values, named_parameter_values=named_parameter_values,
edit_sql_url=edit_sql_url, edit_sql_url=edit_sql_url,
display_rows=await display_rows( display_rows=await display_rows(