mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
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:
co-authored by
Yash Janoria
Evan
Claude Opus 4.8
Evan Rusackas
parent
8181917f79
commit
42e4030104
@@ -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
|
||||
|
||||
Vendored
+7
@@ -11383,6 +11383,13 @@
|
||||
},
|
||||
"User3": {
|
||||
"properties": {
|
||||
"attributes": {
|
||||
"additionalProperties": {
|
||||
"nullable": true
|
||||
},
|
||||
"nullable": true,
|
||||
"type": "object"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user