Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Code
985bfdc752 feat(security): support guest-token revocation per embedded dashboard [DRAFT]
Guest tokens are self-contained JWTs with no revocation: when an admin revokes
embedded access, existing tokens stay valid until exp (ASVS 7.4.1, CWE-613).

Add a guest_token_revoked_before column (epoch seconds) to embedded_dashboards
(migration c8d2e3f4a5b6) and reject, in get_guest_user_from_request, any guest
token whose iat predates the revocation cutoff of one of its embedded-dashboard
resources. Add SecurityManager.revoke_guest_token_access(embedded_uuid) to set
the cutoff to now. Guest tokens already carry iat, so no token format change.

DRAFT: implements the SIP's Part A3 mechanism. Wiring revoke_guest_token_access
into an admin UI / REST action is a follow-up. The revocation check adds a DAO
lookup per guest request; needs validation under embedded load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:22:22 -07:00
4 changed files with 152 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
# 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.
"""add guest_token_revoked_before to embedded_dashboards
Revision ID: c8d2e3f4a5b6
Revises: 33d7e0e21daa
Create Date: 2026-06-01 00:10:00.000000
"""
import sqlalchemy as sa
from superset.migrations.shared.utils import add_columns, drop_columns
# revision identifiers, used by Alembic.
revision = "c8d2e3f4a5b6"
down_revision = "33d7e0e21daa"
def upgrade():
# Epoch seconds; guest tokens for this embedded dashboard issued (iat)
# before this value are rejected. NULL = no revocation.
add_columns(
"embedded_dashboards",
sa.Column("guest_token_revoked_before", sa.Integer(), nullable=True),
)
def downgrade():
drop_columns("embedded_dashboards", "guest_token_revoked_before")

View File

@@ -40,6 +40,10 @@ class EmbeddedDashboard(Model, AuditMixinNullable):
uuid = Column(UUIDType(binary=True), default=uuid.uuid4, primary_key=True)
allow_domain_list = Column(Text) # reference the `allowed_domains` property instead
# Epoch seconds; guest tokens whose `iat` predates this are rejected. Set to
# "now" to revoke all currently-issued guest tokens for this embedded
# dashboard. NULL = no revocation.
guest_token_revoked_before = Column(Integer, nullable=True)
dashboard_id = Column(
Integer,
ForeignKey("dashboards.id", ondelete="CASCADE"),

View File

@@ -3475,6 +3475,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
raise ValueError("Guest token does not contain an rls_rules claim")
if token.get("type") != "guest":
raise ValueError("This is not a guest token.")
if self._is_guest_token_revoked(token):
raise ValueError("Guest token has been revoked")
except Exception: # pylint: disable=broad-except
# The login manager will handle sending 401s.
# We don't need to send a special error message.
@@ -3483,6 +3485,44 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
return self.get_guest_user_from_token(cast(GuestToken, token))
@staticmethod
def _is_guest_token_revoked(token: dict[str, Any]) -> bool:
"""Return True if the token predates a revocation on any of its
embedded-dashboard resources (``guest_token_revoked_before``)."""
issued_at = token.get("iat")
if not issued_at:
return False
# pylint: disable=import-outside-toplevel
from superset.daos.dashboard import EmbeddedDashboardDAO
for resource in token.get("resources") or []:
if resource.get("type") != GuestTokenResourceType.DASHBOARD.value:
continue
embedded = EmbeddedDashboardDAO.find_by_id(str(resource.get("id")))
revoked_before = getattr(embedded, "guest_token_revoked_before", None)
if revoked_before is not None and issued_at < revoked_before:
return True
return False
def revoke_guest_token_access(
self, embedded_uuid: str, before: Optional[int] = None
) -> None:
"""Revoke all guest tokens issued for an embedded dashboard before
``before`` (epoch seconds, default: now). Subsequent tokens are
unaffected."""
# pylint: disable=import-outside-toplevel
from superset import db
from superset.daos.dashboard import EmbeddedDashboardDAO
embedded = EmbeddedDashboardDAO.find_by_id(str(embedded_uuid))
if embedded is None:
return
embedded.guest_token_revoked_before = (
before if before is not None else int(self._get_current_epoch_time())
)
db.session.commit()
def get_guest_user_from_token(self, token: GuestToken) -> GuestUser:
return self.guest_user_cls(
token=token,

View File

@@ -0,0 +1,64 @@
# 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.
from typing import Any
from unittest.mock import MagicMock, patch
from superset.security.manager import SupersetSecurityManager
_DASHBOARD_RESOURCE = {"type": "dashboard", "id": "abc-uuid"}
def _token(iat: int) -> dict[str, Any]:
return {"type": "guest", "iat": iat, "resources": [_DASHBOARD_RESOURCE]}
def _embedded(revoked_before) -> MagicMock:
embedded = MagicMock()
embedded.guest_token_revoked_before = revoked_before
return embedded
def test_guest_token_not_revoked_when_no_revocation_set() -> None:
with patch(
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
return_value=_embedded(None),
):
assert SupersetSecurityManager._is_guest_token_revoked(_token(1000)) is False
def test_guest_token_revoked_when_issued_before_revocation() -> None:
with patch(
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
return_value=_embedded(2000),
):
# Token issued at 1000, revocation at 2000 -> revoked.
assert SupersetSecurityManager._is_guest_token_revoked(_token(1000)) is True
def test_guest_token_valid_when_issued_after_revocation() -> None:
with patch(
"superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
return_value=_embedded(2000),
):
# Token issued at 3000, after the revocation cutoff -> still valid.
assert SupersetSecurityManager._is_guest_token_revoked(_token(3000)) is False
def test_guest_token_without_iat_is_not_revoked() -> None:
token = {"type": "guest", "resources": [_DASHBOARD_RESOURCE]}
assert SupersetSecurityManager._is_guest_token_revoked(token) is False