feat(security): add guest user attributes and get_guest_user_attribute() macro (#33924)

Co-authored-by: Yash Janoria <yash.janoria@314ecorp.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
This commit is contained in:
Yash Janoria
2026-08-08 16:56:07 -07:00
committed by GitHub
co-authored by Yash Janoria Evan Claude Opus 4.8 Evan Rusackas
parent 8181917f79
commit 42e4030104
10 changed files with 1743 additions and 17 deletions
@@ -315,6 +315,76 @@ Here's a concrete example:
WHERE country_code = 'US'
```
**Guest User Attributes**
The `{{ get_guest_user_attribute('attribute_name') }}` macro returns a specific attribute value from the guest user context.
This is useful when working with embedded Superset where guest tokens can contain custom attributes that need to be
accessed in SQL queries.
This macro only works when the current user is a guest user (authenticated via guest token). If the current user is
not a guest user, or if the specified attribute doesn't exist, the macro will return `None` or the provided default value.
If you have caching enabled in your Superset configuration, then by default the resolved value (whether it
came from the guest token, a null attribute, or the provided default) 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. Including the resolved value on every branch ensures two guests
whose tokens render different SQL never share a cache entry.
You can disable the inclusion of the attribute value in the calculation of the
cache key by adding the following parameter to your Jinja code, but only do so
when the value cannot affect the query results:
```
{{ get_guest_user_attribute('department', add_to_cache_keys=False) }}
```
You can also provide a default value if the attribute is not found:
```
{{ get_guest_user_attribute('region', default='US') }}
```
Here's a concrete example of using guest user attributes in a query:
```sql
SELECT *
FROM sales_data
WHERE region = '{{ get_guest_user_attribute("user_region", default="global") }}'
AND department = '{{ get_guest_user_attribute("department") }}'
```
:::warning[Security Warning]
Guest token attributes come from the embedding application. By default,
`get_guest_user_attribute()` escapes string values — including strings nested inside
arrays and object values, and caller-supplied defaults — through the database dialect's
literal rendering (the same mechanism as `url_param()`). This covers dialect-specific
escape characters such as the backslash on MySQL/MariaDB, so the example above is safe
to interpolate directly. If you pass `escape_result=False`, or interpolate non-string
values (numbers, booleans), you are responsible for validating or allowlisting the
values, since they originate outside Superset.
If a guest attribute is an array and you plan to pipe it through the `|where_in` filter
(for example `full_name IN {{ get_guest_user_attribute('names')|where_in }}`), call
`get_guest_user_attribute('names', escape_result=False)`. `where_in` already applies its
own dialect-safe quoting, so escaping the values twice can corrupt them (a value such as
`O'Brien` would come back doubly escaped and match nothing).
Only individual string values are escaped as SQL literals. Interpolating an entire array
or object directly (rather than through `|where_in`, or by accessing a specific element)
renders Python's string form of that structure, which is not valid SQL, and object keys
are not escaped at all. Use `|where_in` for arrays, `|tojson` where you need a
JSON-stringified value, or read individual keys/elements out of the structure yourself.
The same double-escaping problem described above for `|where_in` applies to `|tojson`:
pass `escape_result=False` before piping to `|tojson` (for example
`{{ get_guest_user_attribute('profile', escape_result=False)|tojson }}`), since JSON
already handles its own quoting and re-escaping a value first would corrupt it (a nested
string such as `O'Brien` would come back as the altered `O''Brien` in the serialized
JSON).
:::
### Explicitly Including Values in Cache Key
The `{{ cache_key_wrapper() }}` function explicitly instructs Superset to add a value to the
+7
View File
@@ -11383,6 +11383,13 @@
},
"User3": {
"properties": {
"attributes": {
"additionalProperties": {
"nullable": true
},
"nullable": true,
"type": "object"
},
"first_name": {
"type": "string"
},
+5
View File
@@ -2199,6 +2199,11 @@ class SqlaTable(
templatable_statements += [
f.clause for f in security_manager.get_rls_filters(self)
]
if is_feature_enabled("EMBEDDED_SUPERSET"):
# Guest-token RLS clauses are templated when the query is built, so
# macros they contain (e.g. get_guest_user_attribute) must also
# trigger extra cache key extraction.
templatable_statements += security_manager.get_guest_rls_filters_str(self)
for statement in templatable_statements:
if ExtraCache.regex.search(statement):
return True
+138 -17
View File
@@ -61,6 +61,7 @@ if TYPE_CHECKING:
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
from superset.models.sql_lab import Query
from superset.security.guest_token import GuestToken
logger = logging.getLogger(__name__)
@@ -88,6 +89,11 @@ ALLOWED_TYPES = (
)
COLLECTION_TYPES = ("list", "dict", "tuple", "set")
# Type alias for JSON-native types
JsonValue = Union[
str, int, float, bool, list["JsonValue"], dict[str, "JsonValue"], None
]
@lru_cache(maxsize=LRU_CACHE_MAX_SIZE)
def context_addons() -> dict[str, Any]:
@@ -112,6 +118,25 @@ class TimeFilter:
time_range: str | None
def _normalize_postgresql_backslash_escapes(dialect: Dialect) -> None:
"""Correct a PostgreSQL dialect instance's ``_backslash_escapes`` default
in place so backslashes round-trip unchanged when the dialect is used to
render literals without a live connection.
A dialect built without a live connection (as ``Database.get_dialect()``
does) defaults ``_backslash_escapes`` to ``True``, which would double
every backslash even though every supported PostgreSQL version treats
the backslash as a plain character by default
(``standard_conforming_strings`` has been on since PostgreSQL 9.1). Left
uncorrected, a value like ``C:\\Users`` would be rewritten to
``C:\\\\Users`` and silently fail to match the original value. Other
dialects (for example MySQL/MariaDB, which do treat the backslash as an
escape character) are left untouched.
"""
if dialect.name == "postgresql":
dialect._backslash_escapes = False
class ExtraCache:
"""
Dummy class that exposes a method used to store additional values used in
@@ -128,7 +153,8 @@ class ExtraCache:
r"current_user_rls_rules\([^)]*\)|"
r"current_user_roles\([^)]*\)|"
r"cache_key_wrapper\([^)]*\)|"
r"url_param\([^)]*\)"
r"url_param\([^)]*\)|"
r"get_guest_user_attribute\([^)]*\)"
r")"
r"[^{}]*?(\}\}|\%\})"
)
@@ -295,15 +321,88 @@ class ExtraCache:
result = url_params.get(param, default)
# Escape the value regardless of its source (request args or form
# data); both are interpolated into the rendered SQL.
if result and escape_result and self.dialect:
# use the dialect specific quoting logic to escape string
result = String().literal_processor(dialect=self.dialect)(value=result)[
1:-1
]
if result and escape_result:
# use the dialect-specific literal rendering to escape the string
result = self._escape_value(result)
if add_to_cache_keys:
self.cache_key_wrapper(result)
return result
def get_guest_user_attribute(
self,
attribute_name: str,
default: JsonValue = None,
add_to_cache_keys: bool = True,
escape_result: bool = True,
) -> JsonValue:
"""
Get a specific user attribute from guest user.
This function retrieves attributes from the guest user token and supports
all JSON-native types (string, number, boolean, array, object, null).
Args:
attribute_name: Name of the attribute to retrieve
default: Default value if attribute not found (can be any JSON-native type)
add_to_cache_keys: Whether the resolved value should be included in the
cache key. The resolved value is keyed on every branch (including
the default and null) so two principals whose tokens render
different SQL never share a cache entry. Opting out is only safe
when the value cannot affect the query results.
escape_result: Escape string values (including strings nested inside
lists and object values) through the database dialect's literal
rendering so they are safe to interpolate into SQL, mirroring
``url_param``. Enabled by default; non-string JSON types are
returned unchanged. Set to False for the raw value, in which case
the template author is responsible for validating the value. Pass
``escape_result=False`` when piping a list-valued attribute
through the ``where_in`` filter: ``where_in`` applies its own
dialect-safe quoting, so leaving the default escaping on would
escape each value twice.
Returns:
The attribute value from the guest user token, or the default value.
Can be any JSON-native type: string, number, boolean, array, object, or
null.
Examples:
{{ get_guest_user_attribute('department') }} # Returns: "Engineering"
{{ get_guest_user_attribute('is_admin') }} # Returns: True
{{ get_guest_user_attribute('permissions') }} # Returns: ["read", "write"]
{{ get_guest_user_attribute('config') }} # Returns: {"theme": "dark"}
{{ get_guest_user_attribute('missing', 'default') }} # Returns: "default"
full_name IN {{ get_guest_user_attribute('names', escape_result=False)
|where_in }}
"""
result: JsonValue = default
# The macro only applies to guest users (embedded). is_guest_user()
# handles the feature-flag and request-context checks internally.
if security_manager.is_guest_user():
token: GuestToken = g.user.guest_token
user_attributes: dict[str, JsonValue] = (
token.get("user", {}).get("attributes") or {}
)
result = user_attributes.get(attribute_name, default)
if add_to_cache_keys:
# Key the resolved value on every branch (attribute, default, or
# null); a guest whose attribute is absent renders different SQL
# than one whose attribute is set, so both must contribute to the
# cache key. json.dumps gives a stable serialization for all
# JSON-native types.
cache_value = json.dumps(result, sort_keys=True)
self.cache_key_wrapper(
f"guest_user_attribute:{attribute_name}:{cache_value}"
)
# Guest attributes (and caller-supplied defaults) are interpolated into
# the rendered SQL, so escape strings with the dialect's literal
# rendering by default, mirroring url_param. Non-string JSON types pass
# through.
if escape_result:
result = self._escape_value(result)
return result
def filter_values(
self, column: str, default: str | None = None, remove_filter: bool = False
) -> list[Any]:
@@ -348,22 +447,37 @@ class ExtraCache:
def _escape_value(self, val: Any) -> Any:
"""Return a dialect-quoted form of ``val`` suitable for direct SQL
interpolation. When no dialect is configured the value is returned
unchanged so callers see the raw value as before. Strings are
passed through SQLAlchemy's ``String`` literal processor (with the
surrounding quotes stripped, mirroring ``url_param``). Lists are
processed element-wise; non-string members are left as-is.
unchanged so callers see the raw value as before.
Strings are rendered through the dialect compiler's
``render_literal_value`` (with the surrounding quotes stripped),
which applies dialect-specific escaping beyond quote doubling; in
particular, MySQL/MariaDB treat the backslash as an escape
character, so backslashes are doubled there to prevent a trailing
``\\'`` from re-opening the string literal. Dialects whose escaping
mode cannot be introspected without a live connection err on the
side of over-escaping, which can distort a backslash-containing
value but can never widen the query.
PostgreSQL is special-cased via ``_normalize_postgresql_backslash_escapes``
to restore parity with PostgreSQL's default configuration, while
MySQL/MariaDB keep the stricter, backslash-doubling behavior above.
Lists are processed element-wise and dict values recursively, so
strings nested inside JSON structures are also escaped; dict keys
are left untouched since they are used for member lookups, not
interpolation. Non-string leaf values are left as-is.
"""
if not self.dialect:
return val
if isinstance(val, str):
return String().literal_processor(dialect=self.dialect)(value=val)[1:-1]
compiler = self.dialect.statement_compiler(self.dialect, None)
_normalize_postgresql_backslash_escapes(compiler.dialect)
return compiler.render_literal_value(val, String())[1:-1]
if isinstance(val, list):
return [
String().literal_processor(dialect=self.dialect)(value=v)[1:-1]
if isinstance(v, str)
else v
for v in val
]
return [self._escape_value(v) for v in val]
if isinstance(val, dict):
return {k: self._escape_value(v) for k, v in val.items()}
return val
def get_filters(self, column: str, remove_filter: bool = False) -> list[Filter]:
@@ -672,6 +786,10 @@ def validate_template_context(
class WhereInMacro: # pylint: disable=too-few-public-methods
def __init__(self, dialect: Dialect):
# Without this, a PostgreSQL value like ``C:\Users`` would render as
# ``C:\\Users`` and silently fail to match the original value; see
# ``_normalize_postgresql_backslash_escapes`` for the full rationale.
_normalize_postgresql_backslash_escapes(dialect)
self.dialect = dialect
def __call__(
@@ -917,6 +1035,9 @@ class JinjaTemplateProcessor(BaseTemplateProcessor):
"get_filters": partial(safe_proxy, extra_cache.get_filters),
"dataset": partial(safe_proxy, dataset_macro),
"get_time_filter": partial(safe_proxy, extra_cache.get_time_filter),
"get_guest_user_attribute": partial(
safe_proxy, extra_cache.get_guest_user_attribute
),
}
)
+3
View File
@@ -69,6 +69,9 @@ class UserSchema(PermissiveSchema):
username = fields.String()
first_name = fields.String()
last_name = fields.String()
attributes = fields.Dict(
keys=fields.String(), values=fields.Raw(allow_none=True), allow_none=True
)
class ResourceSchema(PermissiveSchema):
+1
View File
@@ -116,6 +116,7 @@ class GuestTokenUser(TypedDict, total=False):
username: str
first_name: str
last_name: str
attributes: Optional[dict[str, Any]]
class GuestTokenResourceType(StrEnum):
@@ -195,6 +195,174 @@ class TestSecurityGuestTokenApi(SupersetTestCase):
assert user == decoded_token["user"]
assert resource == decoded_token["resources"][0]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_post_guest_token_with_attributes(self) -> None:
"""
Security API: Create a guest token with user attributes
"""
self.dash = db.session.query(Dashboard).filter_by(slug="births").first()
self.embedded = EmbeddedDashboardDAO.upsert(self.dash, [])
self.login(ADMIN_USERNAME)
user = {
"username": "bob_with_attrs",
"first_name": "Bob",
"last_name": "Also Bob",
"attributes": {
"department": "Engineering",
"region": "US",
"role": "developer",
"team": "data-platform",
"clearance_level": "standard",
"projects": ["analytics", "ml-platform"],
"team_lead": True,
},
}
resource = {"type": "dashboard", "id": str(self.embedded.uuid)}
rls_rule = {"dataset": 1, "clause": "1=1"}
params = {"user": user, "resources": [resource], "rls": [rls_rule]}
response = self.client.post(
self.uri, data=json.dumps(params), content_type="application/json"
)
self.assert200(response)
token = json.loads(response.data)["token"]
decoded_token = jwt.decode(
token,
self.app.config["GUEST_TOKEN_JWT_SECRET"],
audience=get_url_host(),
algorithms=["HS256"],
)
# Verify user attributes are preserved in the token
assert user == decoded_token["user"]
assert "attributes" in decoded_token["user"]
assert decoded_token["user"]["attributes"]["department"] == "Engineering"
assert decoded_token["user"]["attributes"]["region"] == "US"
assert decoded_token["user"]["attributes"]["role"] == "developer"
assert decoded_token["user"]["attributes"]["team"] == "data-platform"
assert decoded_token["user"]["attributes"]["clearance_level"] == "standard"
assert decoded_token["user"]["attributes"]["projects"] == [
"analytics",
"ml-platform",
]
assert decoded_token["user"]["attributes"]["team_lead"] is True
assert resource == decoded_token["resources"][0]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_post_guest_token_with_empty_attributes(self) -> None:
"""
Security API: Create a guest token with empty user attributes
"""
self.dash = db.session.query(Dashboard).filter_by(slug="births").first()
self.embedded = EmbeddedDashboardDAO.upsert(self.dash, [])
self.login(ADMIN_USERNAME)
user = {
"username": "bob_empty_attrs",
"first_name": "Bob",
"last_name": "Also Bob",
"attributes": {},
}
resource = {"type": "dashboard", "id": str(self.embedded.uuid)}
rls_rule = {"dataset": 1, "clause": "1=1"}
params = {"user": user, "resources": [resource], "rls": [rls_rule]}
response = self.client.post(
self.uri, data=json.dumps(params), content_type="application/json"
)
self.assert200(response)
token = json.loads(response.data)["token"]
decoded_token = jwt.decode(
token,
self.app.config["GUEST_TOKEN_JWT_SECRET"],
audience=get_url_host(),
algorithms=["HS256"],
)
# Verify empty attributes are preserved in the token
assert user == decoded_token["user"]
assert "attributes" in decoded_token["user"]
assert decoded_token["user"]["attributes"] == {}
assert resource == decoded_token["resources"][0]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_post_guest_token_with_null_attributes(self) -> None:
"""
Security API: Create a guest token with null user attributes
"""
self.dash = db.session.query(Dashboard).filter_by(slug="births").first()
self.embedded = EmbeddedDashboardDAO.upsert(self.dash, [])
self.login(ADMIN_USERNAME)
user = {
"username": "bob_null_attrs",
"first_name": "Bob",
"last_name": "Also Bob",
"attributes": None,
}
resource = {"type": "dashboard", "id": str(self.embedded.uuid)}
rls_rule = {"dataset": 1, "clause": "1=1"}
params = {"user": user, "resources": [resource], "rls": [rls_rule]}
response = self.client.post(
self.uri, data=json.dumps(params), content_type="application/json"
)
self.assert200(response)
token = json.loads(response.data)["token"]
decoded_token = jwt.decode(
token,
self.app.config["GUEST_TOKEN_JWT_SECRET"],
audience=get_url_host(),
algorithms=["HS256"],
)
# Verify null attributes are preserved in the token
assert user == decoded_token["user"]
assert "attributes" in decoded_token["user"]
assert decoded_token["user"]["attributes"] is None
assert resource == decoded_token["resources"][0]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_post_guest_token_without_attributes_backward_compatibility(self) -> None:
"""
Security API: Create a guest token without attributes (backward compatibility)
"""
self.dash = db.session.query(Dashboard).filter_by(slug="births").first()
self.embedded = EmbeddedDashboardDAO.upsert(self.dash, [])
self.login(ADMIN_USERNAME)
user = {
"username": "bob_no_attrs",
"first_name": "Bob",
"last_name": "Also Bob",
# Note: no attributes field
}
resource = {"type": "dashboard", "id": str(self.embedded.uuid)}
rls_rule = {"dataset": 1, "clause": "1=1"}
params = {"user": user, "resources": [resource], "rls": [rls_rule]}
response = self.client.post(
self.uri, data=json.dumps(params), content_type="application/json"
)
self.assert200(response)
token = json.loads(response.data)["token"]
decoded_token = jwt.decode(
token,
self.app.config["GUEST_TOKEN_JWT_SECRET"],
audience=get_url_host(),
algorithms=["HS256"],
)
# Verify user without attributes works and no attributes field is present
assert user == decoded_token["user"]
assert "attributes" not in decoded_token["user"]
assert resource == decoded_token["resources"][0]
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_post_guest_token_bad_resources(self):
self.login(ADMIN_USERNAME)
+182
View File
@@ -37,6 +37,12 @@ from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.models.core import Database
from superset.models.slice import Slice
from superset.security.guest_token import (
GuestTokenResource,
GuestTokenResourceType,
GuestTokenRlsRule,
GuestTokenUser,
)
from superset.sql.parse import Table
from superset.utils.core import (
DatasourceType,
@@ -2331,6 +2337,182 @@ class TestGuestTokens(SupersetTestCase):
assert guest_user is not None
assert "test_guest" == guest_user.username
def create_guest_token_with_attributes(self) -> bytes:
user: GuestTokenUser = {
"username": "test_guest_with_attrs",
"first_name": "Test",
"last_name": "Guest",
"attributes": {
"department": "Engineering",
"region": "US",
"role": "developer",
"team": "data-platform",
},
}
resources: list[GuestTokenResource] = [
{"type": GuestTokenResourceType.DASHBOARD, "id": "test-dashboard"}
]
rls: list[GuestTokenRlsRule] = [{"dataset": "1", "clause": "access = 1"}]
return security_manager.create_guest_access_token(user, resources, rls)
def test_create_guest_access_token_with_attributes(self) -> None:
"""Test creating guest access token with user attributes."""
user_with_attributes: GuestTokenUser = {
"username": "test_guest_attrs",
"first_name": "Test",
"last_name": "Guest",
"attributes": {
"department": "Engineering",
"region": "US",
"clearance_level": "standard",
"projects": ["analytics", "ml-platform"],
"team_lead": True,
},
}
resources: list[GuestTokenResource] = [
{"type": GuestTokenResourceType.DASHBOARD, "id": "test-dashboard"}
]
rls: list[GuestTokenRlsRule] = [{"dataset": "1", "clause": "id = 1"}]
token = security_manager.create_guest_access_token(
user_with_attributes, resources, rls
)
# Decode and verify the token contains attributes
aud = get_url_host()
decoded_token = jwt.decode(
token,
self.app.config["GUEST_TOKEN_JWT_SECRET"],
algorithms=[self.app.config["GUEST_TOKEN_JWT_ALGO"]],
audience=aud,
)
assert "user" in decoded_token
user = decoded_token["user"]
assert "attributes" in user
assert user["attributes"]["department"] == "Engineering"
assert user["attributes"]["region"] == "US"
assert user["attributes"]["clearance_level"] == "standard"
assert user["attributes"]["projects"] == ["analytics", "ml-platform"]
assert user["attributes"]["team_lead"] is True
def test_get_guest_user_with_attributes(self) -> None:
"""Test that guest user properly retains attributes from token."""
token = self.create_guest_token_with_attributes()
fake_request = FakeRequest()
fake_request.headers[current_app.config["GUEST_TOKEN_HEADER_NAME"]] = token
guest_user = security_manager.get_guest_user_from_request(fake_request)
assert guest_user is not None
assert "test_guest_with_attrs" == guest_user.username
# Verify attributes are accessible through guest_token
assert hasattr(guest_user, "guest_token")
token_user = guest_user.guest_token["user"]
assert "attributes" in token_user
token_attributes = token_user["attributes"]
assert token_attributes is not None
assert token_attributes["department"] == "Engineering"
assert token_attributes["region"] == "US"
assert token_attributes["role"] == "developer"
assert token_attributes["team"] == "data-platform"
def test_create_guest_access_token_without_attributes(self) -> None:
"""Test creating guest access token without user attributes.
This test ensures backward compatibility.
"""
user_without_attributes: GuestTokenUser = {
"username": "test_guest_no_attrs",
"first_name": "Test",
"last_name": "Guest",
}
resources: list[GuestTokenResource] = [
{"type": GuestTokenResourceType.DASHBOARD, "id": "test-dashboard"}
]
rls: list[GuestTokenRlsRule] = [{"dataset": "1", "clause": "id = 1"}]
token = security_manager.create_guest_access_token(
user_without_attributes, resources, rls
)
# Decode and verify the token works without attributes
aud = get_url_host()
decoded_token = jwt.decode(
token,
self.app.config["GUEST_TOKEN_JWT_SECRET"],
algorithms=[self.app.config["GUEST_TOKEN_JWT_ALGO"]],
audience=aud,
)
assert "user" in decoded_token
user = decoded_token["user"]
assert "attributes" not in user
assert user["username"] == "test_guest_no_attrs"
def test_create_guest_access_token_with_empty_attributes(self) -> None:
"""Test creating guest access token with empty attributes."""
user_with_empty_attributes: GuestTokenUser = {
"username": "test_guest_empty_attrs",
"first_name": "Test",
"last_name": "Guest",
"attributes": {},
}
resources: list[GuestTokenResource] = [
{"type": GuestTokenResourceType.DASHBOARD, "id": "test-dashboard"}
]
rls: list[GuestTokenRlsRule] = [{"dataset": "1", "clause": "id = 1"}]
token = security_manager.create_guest_access_token(
user_with_empty_attributes, resources, rls
)
# Decode and verify the token contains empty attributes
aud = get_url_host()
decoded_token = jwt.decode(
token,
self.app.config["GUEST_TOKEN_JWT_SECRET"],
algorithms=[self.app.config["GUEST_TOKEN_JWT_ALGO"]],
audience=aud,
)
assert "user" in decoded_token
user = decoded_token["user"]
assert "attributes" in user
assert user["attributes"] == {}
def test_create_guest_access_token_with_null_attributes(self) -> None:
"""Test creating guest access token with null attributes."""
user_with_null_attributes: GuestTokenUser = {
"username": "test_guest_null_attrs",
"first_name": "Test",
"last_name": "Guest",
"attributes": None,
}
resources: list[GuestTokenResource] = [
{"type": GuestTokenResourceType.DASHBOARD, "id": "test-dashboard"}
]
rls: list[GuestTokenRlsRule] = [{"dataset": "1", "clause": "id = 1"}]
token = security_manager.create_guest_access_token(
user_with_null_attributes, resources, rls
)
# Decode and verify the token contains null attributes
aud = get_url_host()
decoded_token = jwt.decode(
token,
self.app.config["GUEST_TOKEN_JWT_SECRET"],
algorithms=[self.app.config["GUEST_TOKEN_JWT_ALGO"]],
audience=aud,
)
assert "user" in decoded_token
user = decoded_token["user"]
assert "attributes" in user
assert user["attributes"] is None
def test_get_guest_user_with_request_form(self):
token = self.create_guest_token()
fake_request = FakeRequest()
@@ -1264,3 +1264,40 @@ def test_validate_stored_expression_rejects_subquery_around_jinja(
None,
"(SELECT password FROM ab_user LIMIT 1) {# x #}",
)
def test_has_extra_cache_key_calls_scans_guest_token_rls(
mocker: MockerFixture,
) -> None:
"""
Guest-token RLS clauses are templated when the query is built, so a macro
appearing only in a guest-token RLS clause must still trigger extra cache
key extraction; otherwise its value never reaches the cache key and two
guests can share a cache entry.
"""
mocker.patch(
"superset.connectors.sqla.models.is_feature_enabled",
side_effect=lambda flag: flag == "EMBEDDED_SUPERSET",
)
mocker.patch(
"superset.connectors.sqla.models.security_manager.get_rls_filters",
return_value=[],
)
get_guest_rls = mocker.patch(
"superset.connectors.sqla.models.security_manager.get_guest_rls_filters",
return_value=[
{"clause": "tenant = '{{ get_guest_user_attribute(\"tenant\") }}'"}
],
)
table = SqlaTable(
table_name="tenanted",
sql="SELECT 1 AS tenant",
database=Database(database_name="db", sqlalchemy_uri="sqlite://"),
)
query_obj: QueryObjectDict = {"metrics": [], "columns": [], "extras": {}}
assert table.has_extra_cache_key_calls(query_obj) is True
get_guest_rls.return_value = [{"clause": "tenant = 'acme'"}]
assert table.has_extra_cache_key_calls(query_obj) is False
File diff suppressed because it is too large Load Diff