mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
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:
parent
1134b22a27
commit
3adddad6aa
4 changed files with 319 additions and 133 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue