You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/06/23 00:54:12 UTC

(superset) 01/01: feat(security): record audit metadata on guest token issuance

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch fix/guest-token-issuance-audit
in repository https://gitbox.apache.org/repos/asf/superset.git

commit d6c562173a6e53b095ff022907cfae60303d829c
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Mon Jun 22 17:53:47 2026 -0700

    feat(security): record audit metadata on guest token issuance
    
    The guest-token issuance endpoint logged only the coarse action via
    @event_logger.log_this, without the metadata needed to investigate a misissued
    or over-scoped token. Emit an explicit issuance event capturing the issuer user
    id, source IP, granted resources, dataset allowlist, RLS dataset scope, and a
    SHA-256 hash of the issued token (never the raw token; RLS clause text is
    omitted since it can carry data values).
    
    The payload is built by a pure helper (build_guest_token_audit_payload) with
    unit tests.
    
    Co-Authored-By: Claude Fable 5 <no...@anthropic.com>
---
 superset/security/api.py                           | 15 ++++-
 superset/security/guest_token.py                   | 35 +++++++++-
 .../unit_tests/security/guest_token_audit_test.py  | 74 ++++++++++++++++++++++
 3 files changed, 122 insertions(+), 2 deletions(-)

diff --git a/superset/security/api.py b/superset/security/api.py
index c38b3c590fe..ebf888045f1 100644
--- a/superset/security/api.py
+++ b/superset/security/api.py
@@ -34,7 +34,11 @@ from superset.commands.dashboard.embedded.exceptions import (
 from superset.commands.exceptions import ForbiddenError
 from superset.exceptions import SupersetGenericErrorException
 from superset.extensions import db, event_logger
-from superset.security.guest_token import GuestTokenResourceType
+from superset.security.guest_token import (
+    build_guest_token_audit_payload,
+    GuestTokenResourceType,
+)
+from superset.utils.core import get_user_id
 from superset.views.base_api import (
     BaseSupersetApi,
     BaseSupersetModelRestApi,
@@ -204,6 +208,15 @@ class SecurityRestApi(BaseSupersetApi):
                 body["rls"],
                 **({"datasets": body["datasets"]} if "datasets" in body else {}),
             )
+            logger.info(
+                "Guest token issued: %s",
+                build_guest_token_audit_payload(
+                    issuer_user_id=get_user_id(),
+                    source_ip=request.remote_addr,
+                    body=body,
+                    token=token,
+                ),
+            )
             return self.response(200, token=token)
         except EmbeddedDashboardNotFoundError as error:
             return self.response_400(message=error.message)
diff --git a/superset/security/guest_token.py b/superset/security/guest_token.py
index 9f1b58fbd92..884d41d827c 100644
--- a/superset/security/guest_token.py
+++ b/superset/security/guest_token.py
@@ -14,8 +14,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import hashlib
 import logging
-from typing import Optional, TypedDict, Union
+from typing import Any, Optional, TypedDict, Union
 
 from flask_appbuilder.security.sqla.models import Group, Role
 from flask_login import AnonymousUserMixin
@@ -24,6 +25,38 @@ from superset.utils.backports import StrEnum
 
 logger = logging.getLogger(__name__)
 
+
+def build_guest_token_audit_payload(
+    issuer_user_id: Optional[int],
+    source_ip: Optional[str],
+    body: dict[str, Any],
+    token: str,
+) -> dict[str, Any]:
+    """Build security-relevant metadata for a guest-token issuance event.
+
+    Captures who issued the token, from where, what it grants, and a hash of
+    the issued token (never the raw token) so a later investigation into a
+    misissued or over-scoped token can be scoped from the audit log.
+    """
+    resources = body.get("resources") or []
+    rls = body.get("rls") or []
+    return {
+        "issuer_user_id": issuer_user_id,
+        "source_ip": source_ip,
+        "resources": [
+            f"{resource.get('type')}:{resource.get('id')}" for resource in resources
+        ],
+        "datasets": body.get("datasets"),
+        # RLS clauses can carry data values; record only the datasets in scope
+        # and the rule count, not the clause text.
+        "rls_datasets": [rule.get("dataset") for rule in rls],
+        "rls_rule_count": len(rls),
+        # Hash, not the raw token, so the log can be correlated without
+        # becoming a credential store.
+        "token_sha256": hashlib.sha256(token.encode("utf-8")).hexdigest(),
+    }
+
+
 # JWT claim that carries the revocation version a token was minted with.
 GUEST_TOKEN_REVOCATION_CLAIM = "rev"  # noqa: S105
 
diff --git a/tests/unit_tests/security/guest_token_audit_test.py b/tests/unit_tests/security/guest_token_audit_test.py
new file mode 100644
index 00000000000..782c9ba548d
--- /dev/null
+++ b/tests/unit_tests/security/guest_token_audit_test.py
@@ -0,0 +1,74 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for guest-token issuance audit metadata."""
+
+import hashlib
+
+from superset.security.guest_token import build_guest_token_audit_payload
+
+
+def test_build_guest_token_audit_payload_captures_issuance_metadata():
+    body = {
+        "user": {"username": "guest"},
+        "resources": [{"type": "dashboard", "id": "abc-123"}],
+        "datasets": [1, 2],
+        "rls": [{"dataset": 1, "clause": "tenant_id = 9"}],
+    }
+    payload = build_guest_token_audit_payload(
+        issuer_user_id=42,
+        source_ip="10.0.0.1",
+        body=body,
+        token="the-secret-token",  # noqa: S106
+    )
+
+    assert payload["issuer_user_id"] == 42
+    assert payload["source_ip"] == "10.0.0.1"
+    assert payload["resources"] == ["dashboard:abc-123"]
+    assert payload["datasets"] == [1, 2]
+    assert payload["rls_datasets"] == [1]
+    assert payload["rls_rule_count"] == 1
+
+
+def test_build_guest_token_audit_payload_hashes_token_and_omits_raw():
+    token = "the-secret-token"  # noqa: S105
+    payload = build_guest_token_audit_payload(
+        issuer_user_id=1,
+        source_ip=None,
+        body={"resources": [], "rls": []},
+        token=token,
+    )
+
+    # The raw token is never present; only its hash is recorded.
+    assert token not in payload.values()
+    assert payload["token_sha256"] == hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def test_build_guest_token_audit_payload_omits_rls_clause_text():
+    body = {
+        "resources": [],
+        "rls": [{"dataset": 7, "clause": "secret_value = 'pii'"}],
+    }
+    payload = build_guest_token_audit_payload(
+        issuer_user_id=1,
+        source_ip=None,
+        body=body,
+        token="t",  # noqa: S106
+    )
+
+    # Clause text (which can carry data values) is not recorded.
+    assert "secret_value = 'pii'" not in str(payload)
+    assert payload["rls_datasets"] == [7]