You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by va...@apache.org on 2025/05/29 14:58:46 UTC
(superset) branch master updated: feat: current_user_rls_rules Jinja macro (#33614)
This is an automated email from the ASF dual-hosted git repository.
vavila pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new fdea4e21b0 feat: current_user_rls_rules Jinja macro (#33614)
fdea4e21b0 is described below
commit fdea4e21b0d38b9c0b1eddb4c790619fbc499e5c
Author: Vitor Avila <96...@users.noreply.github.com>
AuthorDate: Thu May 29 11:58:40 2025 -0300
feat: current_user_rls_rules Jinja macro (#33614)
---
docs/docs/configuration/sql-templating.mdx | 8 +++
superset/jinja_context.py | 93 ++++++++++++++++++----------
tests/unit_tests/jinja_context_test.py | 97 ++++++++++++++++++++++--------
3 files changed, 140 insertions(+), 58 deletions(-)
diff --git a/docs/docs/configuration/sql-templating.mdx b/docs/docs/configuration/sql-templating.mdx
index dcd5bb0869..d2b74afa1a 100644
--- a/docs/docs/configuration/sql-templating.mdx
+++ b/docs/docs/configuration/sql-templating.mdx
@@ -250,6 +250,14 @@ Will be rendered as:
SELECT * FROM users WHERE role IN ('admin', 'viewer')
```
+**Current User RLS Rules**
+
+The `{{ current_user_rls_rules() }}` macro returns an array of RLS rules applied to the current dataset for the logged in user.
+
+If you have caching enabled in your Superset configuration, then the list of RLS Rules will be used
+by Superset when calculating the cache key. A cache key is a unique identifier that determines if there's a
+cache hit in the future and Superset can retrieve cached data.
+
**Custom URL Parameters**
The `{{ url_param('custom_variable') }}` macro lets you define arbitrary URL
diff --git a/superset/jinja_context.py b/superset/jinja_context.py
index 9765ccbdc1..837150f033 100644
--- a/superset/jinja_context.py
+++ b/superset/jinja_context.py
@@ -22,7 +22,7 @@ import re
from dataclasses import dataclass
from datetime import datetime
from functools import lru_cache, partial
-from typing import Any, Callable, cast, Optional, TYPE_CHECKING, TypedDict, Union
+from typing import Any, Callable, cast, TYPE_CHECKING, TypedDict, Union
import dateutil
from flask import current_app, g, has_request_context, request
@@ -109,6 +109,7 @@ class ExtraCache:
r"current_user_id\([^()]*\)|"
r"current_username\([^()]*\)|"
r"current_user_email\([^()]*\)|"
+ r"current_user_rls_rules\([^()]*\)|"
r"current_user_roles\([^()]*\)|"
r"cache_key_wrapper\([^()]*\)|"
r"url_param\([^()]*\)"
@@ -118,12 +119,12 @@ class ExtraCache:
def __init__( # pylint: disable=too-many-arguments
self,
- extra_cache_keys: Optional[list[Any]] = None,
- applied_filters: Optional[list[str]] = None,
- removed_filters: Optional[list[str]] = None,
- database: Optional[Database] = None,
- dialect: Optional[Dialect] = None,
- table: Optional[SqlaTable] = None,
+ extra_cache_keys: list[Any] | None = None,
+ applied_filters: list[str] | None = None,
+ removed_filters: list[str] | None = None,
+ database: Database | None = None,
+ dialect: Dialect | None = None,
+ table: SqlaTable | None = None,
):
self.extra_cache_keys = extra_cache_keys
self.applied_filters = applied_filters if applied_filters is not None else []
@@ -132,7 +133,7 @@ class ExtraCache:
self.dialect = dialect
self.table = table
- def current_user_id(self, add_to_cache_keys: bool = True) -> Optional[int]:
+ def current_user_id(self, add_to_cache_keys: bool = True) -> int | None:
"""
Return the user ID of the user who is currently logged in.
@@ -146,7 +147,7 @@ class ExtraCache:
return user_id
return None
- def current_username(self, add_to_cache_keys: bool = True) -> Optional[str]:
+ def current_username(self, add_to_cache_keys: bool = True) -> str | None:
"""
Return the username of the user who is currently logged in.
@@ -160,7 +161,7 @@ class ExtraCache:
return username
return None
- def current_user_email(self, add_to_cache_keys: bool = True) -> Optional[str]:
+ def current_user_email(self, add_to_cache_keys: bool = True) -> str | None:
"""
Return the email address of the user who is currently logged in.
@@ -193,6 +194,31 @@ class ExtraCache:
except Exception: # pylint: disable=broad-except
return None
+ def current_user_rls_rules(self) -> list[str] | None:
+ """
+ Return the row level security rules applied to the current user and dataset.
+ """
+ if not self.table:
+ return None
+
+ rls_rules = (
+ sorted(
+ [
+ rule["clause"]
+ for rule in security_manager.get_guest_rls_filters(self.table)
+ ]
+ )
+ if security_manager.is_guest_user()
+ else sorted(
+ [rule.clause for rule in security_manager.get_rls_filters(self.table)]
+ )
+ )
+ if not rls_rules:
+ return None
+
+ self.cache_key_wrapper(json.dumps(rls_rules))
+ return rls_rules
+
def cache_key_wrapper(self, key: Any) -> Any:
"""
Adds values to a list that is added to the query object used for calculating a
@@ -213,10 +239,10 @@ class ExtraCache:
def url_param(
self,
param: str,
- default: Optional[str] = None,
+ default: str | None = None,
add_to_cache_keys: bool = True,
escape_result: bool = True,
- ) -> Optional[str]:
+ ) -> str | None:
"""
Read a url or post parameter and use it in your SQL Lab query.
@@ -259,7 +285,7 @@ class ExtraCache:
return result
def filter_values(
- self, column: str, default: Optional[str] = None, remove_filter: bool = False
+ self, column: str, default: str | None = None, remove_filter: bool = False
) -> list[Any]:
"""Gets a values for a particular filter as a list
@@ -524,7 +550,7 @@ def validate_context_types(context: dict[str, Any]) -> dict[str, Any]:
def validate_template_context(
- engine: Optional[str], context: dict[str, Any]
+ engine: str | None, context: dict[str, Any]
) -> dict[str, Any]:
if engine and engine in context:
# validate engine context separately to allow for engine-specific methods
@@ -543,7 +569,7 @@ class WhereInMacro: # pylint: disable=too-few-public-methods
def __call__(
self,
values: list[Any],
- mark: Optional[str] = None,
+ mark: str | None = None,
default_to_none: bool = False,
) -> str | None:
"""
@@ -605,17 +631,17 @@ class BaseTemplateProcessor:
Base class for database-specific jinja context
"""
- engine: Optional[str] = None
+ engine: str | None = None
# pylint: disable=too-many-arguments
def __init__(
self,
database: "Database",
- query: Optional["Query"] = None,
- table: Optional["SqlaTable"] = None,
- extra_cache_keys: Optional[list[Any]] = None,
- removed_filters: Optional[list[str]] = None,
- applied_filters: Optional[list[str]] = None,
+ query: "Query" | None = None,
+ table: "SqlaTable" | None = None,
+ extra_cache_keys: list[Any] | None = None,
+ removed_filters: list[str] | None = None,
+ applied_filters: list[str] | None = None,
**kwargs: Any,
) -> None:
self._database = database
@@ -667,7 +693,7 @@ class BaseTemplateProcessor:
class JinjaTemplateProcessor(BaseTemplateProcessor):
- def _parse_datetime(self, dttm: str) -> Optional[datetime]:
+ def _parse_datetime(self, dttm: str) -> datetime | None:
"""
Try to parse a datetime and default to None in the worst case.
@@ -719,6 +745,9 @@ class JinjaTemplateProcessor(BaseTemplateProcessor):
"current_user_roles": partial(
safe_proxy, extra_cache.current_user_roles
),
+ "current_user_rls_rules": partial(
+ safe_proxy, extra_cache.current_user_rls_rules
+ ),
"cache_key_wrapper": partial(safe_proxy, extra_cache.cache_key_wrapper),
"filter_values": partial(safe_proxy, extra_cache.filter_values),
"get_filters": partial(safe_proxy, extra_cache.get_filters),
@@ -763,14 +792,12 @@ class PrestoTemplateProcessor(JinjaTemplateProcessor):
}
@staticmethod
- def _schema_table(
- table_name: str, schema: Optional[str]
- ) -> tuple[str, Optional[str]]:
+ def _schema_table(table_name: str, schema: str | None) -> tuple[str, str | None]:
if "." in table_name:
schema, table_name = table_name.split(".")
return table_name, schema
- def first_latest_partition(self, table_name: str) -> Optional[str]:
+ def first_latest_partition(self, table_name: str) -> str | None:
"""
Gets the first value in the array of all latest partitions
@@ -782,7 +809,7 @@ class PrestoTemplateProcessor(JinjaTemplateProcessor):
latest_partitions = self.latest_partitions(table_name)
return latest_partitions[0] if latest_partitions else None
- def latest_partitions(self, table_name: str) -> Optional[list[str]]:
+ def latest_partitions(self, table_name: str) -> list[str] | None:
"""
Gets the array of all latest partitions
@@ -864,8 +891,8 @@ def get_template_processors() -> dict[str, Any]:
def get_template_processor(
database: "Database",
- table: Optional["SqlaTable"] = None,
- query: Optional["Query"] = None,
+ table: "SqlaTable" | None = None,
+ query: "Query" | None = None,
**kwargs: Any,
) -> BaseTemplateProcessor:
if feature_flag_manager.is_feature_enabled("ENABLE_TEMPLATE_PROCESSING"):
@@ -880,9 +907,9 @@ def get_template_processor(
def dataset_macro(
dataset_id: int,
include_metrics: bool = False,
- columns: Optional[list[str]] = None,
- from_dttm: Optional[datetime] = None,
- to_dttm: Optional[datetime] = None,
+ columns: list[str] | None = None,
+ from_dttm: datetime | None = None,
+ to_dttm: datetime | None = None,
) -> str:
"""
Given a dataset ID, return the SQL that represents it.
@@ -964,7 +991,7 @@ def metric_macro(
env: Environment,
context: dict[str, Any],
metric_key: str,
- dataset_id: Optional[int] = None,
+ dataset_id: int | None = None,
) -> str:
"""
Given a metric key, returns its syntax.
diff --git a/tests/unit_tests/jinja_context_test.py b/tests/unit_tests/jinja_context_test.py
index fa79cc0493..4cb8cbec2d 100644
--- a/tests/unit_tests/jinja_context_test.py
+++ b/tests/unit_tests/jinja_context_test.py
@@ -31,7 +31,12 @@ from sqlalchemy.dialects.postgresql import dialect
from superset import app
from superset.commands.dataset.exceptions import DatasetNotFoundError
-from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
+from superset.connectors.sqla.models import (
+ RowLevelSecurityFilter,
+ SqlaTable,
+ SqlMetric,
+ TableColumn,
+)
from superset.exceptions import SupersetTemplateException
from superset.jinja_context import (
dataset_macro,
@@ -46,6 +51,7 @@ from superset.jinja_context import (
from superset.models.core import Database
from superset.models.slice import Slice
from superset.utils import json
+from tests.unit_tests.conftest import with_feature_flags
def test_filter_values_adhoc_filters() -> None:
@@ -355,16 +361,29 @@ def test_safe_proxy_nested_lambda() -> None:
safe_proxy(func, {"foo": lambda: "bar"})
-def test_user_macros(mocker: MockerFixture):
+@pytest.mark.parametrize(
+ "add_to_cache_keys,mock_cache_key_wrapper_call_count",
+ [
+ (True, 4),
+ (False, 0),
+ ],
+)
+def test_user_macros(
+ mocker: MockerFixture,
+ add_to_cache_keys: bool,
+ mock_cache_key_wrapper_call_count: int,
+):
"""
Test all user macros:
- ``current_user_id``
- ``current_username``
- ``current_user_email``
- ``current_user_roles``
+ - ``current_user_rls_rules``
"""
mock_g = mocker.patch("superset.utils.core.g")
mock_get_user_roles = mocker.patch("superset.security_manager.get_user_roles")
+ mock_get_user_rls = mocker.patch("superset.security_manager.get_rls_filters")
mock_cache_key_wrapper = mocker.patch(
"superset.jinja_context.ExtraCache.cache_key_wrapper"
)
@@ -372,49 +391,77 @@ def test_user_macros(mocker: MockerFixture):
mock_g.user.username = "my_username"
mock_g.user.email = "my_email@test.com"
mock_get_user_roles.return_value = [Role(name="my_role1"), Role(name="my_role2")]
- cache = ExtraCache()
- assert cache.current_user_id() == 1
- assert cache.current_username() == "my_username"
- assert cache.current_user_email() == "my_email@test.com"
- assert cache.current_user_roles() == ["my_role1", "my_role2"]
- assert mock_cache_key_wrapper.call_count == 4
+ mock_get_user_rls.return_value = [
+ RowLevelSecurityFilter(group_key="test", clause="1=1"),
+ RowLevelSecurityFilter(group_key="other_test", clause="product_id=1"),
+ ]
+ cache = ExtraCache(table=mocker.MagicMock())
+ assert cache.current_user_id(add_to_cache_keys) == 1
+ assert cache.current_username(add_to_cache_keys) == "my_username"
+ assert cache.current_user_email(add_to_cache_keys) == "my_email@test.com"
+ assert cache.current_user_roles(add_to_cache_keys) == ["my_role1", "my_role2"]
+ assert mock_cache_key_wrapper.call_count == mock_cache_key_wrapper_call_count
+
+ # Testing {{ current_user_rls_rules() }} macro isolated and always without
+ # the param because it does not support it to avoid shared cache.
+ assert cache.current_user_rls_rules() == ["1=1", "product_id=1"]
- mock_get_user_roles.return_value = []
+
+def test_user_macros_without_user_info(mocker: MockerFixture):
+ """
+ Test all user macros when no user info is available.
+ """
+ mock_g = mocker.patch("superset.utils.core.g")
+ mock_g.user = None
+ cache = ExtraCache(table=mocker.MagicMock())
+ assert cache.current_user_id() is None
+ assert cache.current_username() is None
+ assert cache.current_user_email() is None
assert cache.current_user_roles() is None
+ assert cache.current_user_rls_rules() is None
-def test_user_macros_without_cache_key_inclusion(mocker: MockerFixture):
+def test_current_user_rls_rules_with_no_table(mocker: MockerFixture):
"""
- Test all user macros with ``add_to_cache_keys`` set to ``False``.
+ Test the ``current_user_rls_rules`` macro when no table is provided.
"""
mock_g = mocker.patch("superset.utils.core.g")
- mock_get_user_roles = mocker.patch("superset.security_manager.get_user_roles")
+ mock_get_user_rls = mocker.patch("superset.security_manager.get_rls_filters")
+ mock_is_guest_user = mocker.patch("superset.security_manager.is_guest_user")
mock_cache_key_wrapper = mocker.patch(
"superset.jinja_context.ExtraCache.cache_key_wrapper"
)
mock_g.user.id = 1
mock_g.user.username = "my_username"
mock_g.user.email = "my_email@test.com"
- mock_get_user_roles.return_value = [Role(name="my_role1"), Role(name="my_role2")]
cache = ExtraCache()
- assert cache.current_user_id(False) == 1
- assert cache.current_username(False) == "my_username"
- assert cache.current_user_email(False) == "my_email@test.com"
- assert cache.current_user_roles(False) == ["my_role1", "my_role2"]
+ assert cache.current_user_rls_rules() is None
assert mock_cache_key_wrapper.call_count == 0
+ assert mock_get_user_rls.call_count == 0
+ assert mock_is_guest_user.call_count == 0
-def test_user_macros_without_user_info(mocker: MockerFixture):
+@with_feature_flags(EMBEDDED_SUPERSET=True)
+def test_current_user_rls_rules_guest_user(mocker: MockerFixture):
"""
- Test all user macros when no user info is available.
+ Test the ``current_user_rls_rules`` with an embedded user.
"""
mock_g = mocker.patch("superset.utils.core.g")
- mock_g.user = None
- cache = ExtraCache()
- assert cache.current_user_id() == None # noqa: E711
- assert cache.current_username() == None # noqa: E711
- assert cache.current_user_email() == None # noqa: E711
- assert cache.current_user_roles() == None # noqa: E711
+ mock_gg = mocker.patch("superset.tasks.utils.g")
+ mock_ggg = mocker.patch("superset.security.manager.g")
+ mock_get_user_rls = mocker.patch("superset.security_manager.get_guest_rls_filters")
+ mock_user = mocker.MagicMock()
+ mock_user.username = "my_username"
+ mock_user.is_guest_user = True
+ mock_user.is_anonymous = False
+ mock_g.user = mock_gg.user = mock_ggg.user = mock_user
+
+ mock_get_user_rls.return_value = [
+ {"group_key": "test", "clause": "1=1"},
+ {"group_key": "other_test", "clause": "product_id=1"},
+ ]
+ cache = ExtraCache(table=mocker.MagicMock())
+ assert cache.current_user_rls_rules() == ["1=1", "product_id=1"]
def test_where_in() -> None: